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/release_window.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Decision half of the nightly daemon-host release window (#2112).
|
|
2
|
+
|
|
3
|
+
`coord release propagate` (#1835/#2067) rolls each host at ITS OWN quiescent
|
|
4
|
+
window — except the daemon host, which the daemon-first lane order (#1835's
|
|
5
|
+
LANE ORDER, the documented 405) forces to gate the *whole run*: no lane may
|
|
6
|
+
roll ahead of an unrolled daemon. dellserver is both the daemon host and a
|
|
7
|
+
work machine, and every unpinned drive-queue entry charges it via
|
|
8
|
+
``launch_host`` (see :func:`coord.release_propagate.busy_host_for_entry`)
|
|
9
|
+
regardless of where the worker actually lands — so almost any drive anywhere
|
|
10
|
+
keeps the daemon "busy" and defers the entire fleet. Measured 2026-08-10: the
|
|
11
|
+
fleet sat eleven releases behind for a day with elitebook idle and rollable
|
|
12
|
+
throughout.
|
|
13
|
+
|
|
14
|
+
#2101 (release cordons) answers this for every OTHER host: cordon it,
|
|
15
|
+
drain it, roll it the moment it's free. It cannot answer it for the daemon
|
|
16
|
+
host itself — cordoning stops NEW work from routing there, but the daemon
|
|
17
|
+
host is what runs the drive-queue tick that launches drives fleet-wide in
|
|
18
|
+
the first place, so cordoning it does not stop new drives from being queued
|
|
19
|
+
against it. The only way to guarantee the daemon host reaches quiescence is
|
|
20
|
+
to stop the thing that launches work onto it. Hence this module: a nightly
|
|
21
|
+
window that stops `coord-drive-queue.timer`, waits (bounded) for whatever is
|
|
22
|
+
already running to finish, rolls, and restarts the timer — always, whether
|
|
23
|
+
or not the roll happened.
|
|
24
|
+
|
|
25
|
+
GATED ON #2110, HARD PREREQUISITE
|
|
26
|
+
----------------------------------
|
|
27
|
+
Steps "stop the timer, wait, roll" are exactly the sequence that deadlocked
|
|
28
|
+
on 2026-08-10: the reconciler that moves a finished drive from `running` to
|
|
29
|
+
`done` lives *inside* `coord drive-queue tick`, so stopping the timer stops
|
|
30
|
+
reconciliation too, and the last drive's row stays `running` forever — the
|
|
31
|
+
daemon host reads as busy permanently and this window would defer forever,
|
|
32
|
+
every night, unattended, and exit 0. #2110 made `coord drive-queue tick
|
|
33
|
+
--reconcile-only` (equivalently `--max-parallel 0`) safe to call on its own,
|
|
34
|
+
which is exactly what the drain loop below needs: reconcile without
|
|
35
|
+
launching anything new.
|
|
36
|
+
|
|
37
|
+
THE THREE TRAPS THIS MODULE IS SHAPED AROUND
|
|
38
|
+
----------------------------------------------
|
|
39
|
+
1. **Never `--force`.** That flag kills in-flight headless workers — the
|
|
40
|
+
whole reason propagation is quiescence-scheduled — and an unattended
|
|
41
|
+
nightly job must never carry it. If the drain does not finish, this
|
|
42
|
+
module says so and declines; it does not reach for the escape hatch.
|
|
43
|
+
2. **The drain is bounded.** :data:`DEFAULT_DRAIN_DEADLINE_SECONDS` is the
|
|
44
|
+
deadline after which the caller must restart the timer and report
|
|
45
|
+
failure rather than leave the queue stopped into the working day — the
|
|
46
|
+
mirror image of "an expired deadline stops the observer, not the work."
|
|
47
|
+
3. **A skipped night must be loud.** Every non-happy status here
|
|
48
|
+
(:data:`STATUS_DRAIN_TIMEOUT`, :data:`STATUS_PROPAGATE_DEFERRED`,
|
|
49
|
+
:data:`STATUS_PROPAGATE_FAILED`, :data:`STATUS_ERROR`) is something the
|
|
50
|
+
I/O shell (`coord/commands/release.py`'s `nightly-window` command) is
|
|
51
|
+
expected to escalate through `coord.state.record_drive_escalation` — the
|
|
52
|
+
same channel #2101's drain-deadline escalation and #2082 exist to make
|
|
53
|
+
this class of silence impossible.
|
|
54
|
+
|
|
55
|
+
Architecture mirrors `coord/release_propagate.py` and `coord/release_cordon.py`
|
|
56
|
+
on purpose: this module is pure decision-making over already-fetched facts
|
|
57
|
+
(a version string, a deadline, elapsed time) plus the journal format, so it
|
|
58
|
+
is unit-testable with no fleet, no systemd and no board. Everything that
|
|
59
|
+
needs a live host — stopping/starting the timer, running the reconcile-only
|
|
60
|
+
tick, invoking `coord release propagate`, writing the escalation — lives in
|
|
61
|
+
the command's I/O shell, next to `release_propagate`'s own for the same
|
|
62
|
+
reason (see that module's docstring's "what lives here" split).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import json
|
|
68
|
+
from dataclasses import asdict, dataclass
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
from typing import Any, Mapping
|
|
71
|
+
|
|
72
|
+
#: The systemd --user timer this window stops for the duration of the
|
|
73
|
+
#: drain. Overridable (`--queue-timer`) purely for tests and for an
|
|
74
|
+
#: unusual install that renamed the unit; production has exactly one name.
|
|
75
|
+
DEFAULT_QUEUE_TIMER = "coord-drive-queue.timer"
|
|
76
|
+
|
|
77
|
+
#: Bounded wait (trap 2) for in-flight drives to finish before the window
|
|
78
|
+
#: gives up, restarts the queue and reports failure. An hour leaves ample
|
|
79
|
+
#: room inside the 22:00-08:00 quiet-hours window even started as late as
|
|
80
|
+
#: 03:00 — the queue is never stopped anywhere near the working day.
|
|
81
|
+
DEFAULT_DRAIN_DEADLINE_SECONDS = 3600.0
|
|
82
|
+
|
|
83
|
+
#: How often the drain loop reconciles the queue and re-checks quiescence.
|
|
84
|
+
#: `coord-drive-queue.timer` itself fires every 3 minutes in production —
|
|
85
|
+
#: polling much faster than that buys nothing (nothing on the board can
|
|
86
|
+
#: change faster) and just spends `coord drive-queue tick --reconcile-only`
|
|
87
|
+
#: subprocesses for no reason.
|
|
88
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 30.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── status vocabulary ────────────────────────────────────────────────────
|
|
92
|
+
#
|
|
93
|
+
# A small closed set, mirroring release_propagate.py's STATUS_* — so an
|
|
94
|
+
# operator who already knows how to read `coord release history` does not
|
|
95
|
+
# have to learn a second vocabulary for this journal.
|
|
96
|
+
|
|
97
|
+
STATUS_UP_TO_DATE = "up-to-date"
|
|
98
|
+
STATUS_DRY_RUN = "dry-run"
|
|
99
|
+
STATUS_ROLLED = "rolled"
|
|
100
|
+
STATUS_DRAIN_TIMEOUT = "drain-timeout"
|
|
101
|
+
STATUS_PROPAGATE_DEFERRED = "propagate-deferred"
|
|
102
|
+
STATUS_PROPAGATE_FAILED = "propagate-failed"
|
|
103
|
+
STATUS_ERROR = "error"
|
|
104
|
+
|
|
105
|
+
#: Statuses meaning "this window did what it was for, or correctly had
|
|
106
|
+
#: nothing to do" — everything else is a night propagation was supposed to
|
|
107
|
+
#: happen and did not (trap 3: loud, not silent).
|
|
108
|
+
OK_STATUSES = frozenset({STATUS_UP_TO_DATE, STATUS_ROLLED, STATUS_DRY_RUN})
|
|
109
|
+
|
|
110
|
+
#: The inverse of OK_STATUSES, spelled out for readability at call sites.
|
|
111
|
+
LOUD_STATUSES = frozenset(
|
|
112
|
+
{STATUS_DRAIN_TIMEOUT, STATUS_PROPAGATE_DEFERRED, STATUS_PROPAGATE_FAILED, STATUS_ERROR}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def needs_roll(daemon_version: str | None, target_version: str | None) -> bool:
|
|
117
|
+
"""Is there anything for this window to do?
|
|
118
|
+
|
|
119
|
+
#2112 acceptance 3: "with the fleet already current, the job does not
|
|
120
|
+
stop the queue at all." This is the check that has to answer that
|
|
121
|
+
*before* anything touches the queue.
|
|
122
|
+
|
|
123
|
+
`daemon_version=None` (no data — an unreachable daemon host, an
|
|
124
|
+
unreadable python lane) reads as NEEDS a roll, never as "current": #1834's
|
|
125
|
+
rule is that no-data is not evidence of agreement, and skipping the
|
|
126
|
+
window on a guess is exactly the silent-no-op shape this issue exists to
|
|
127
|
+
close. Delegates the actual comparison to
|
|
128
|
+
:func:`coord.release_cordon.version_drift` rather than a third
|
|
129
|
+
reimplementation of version arithmetic in this codebase.
|
|
130
|
+
"""
|
|
131
|
+
from coord.release_cordon import version_drift # noqa: PLC0415
|
|
132
|
+
|
|
133
|
+
if not target_version:
|
|
134
|
+
return False
|
|
135
|
+
drift = version_drift(daemon_version, target_version)
|
|
136
|
+
return drift is None or drift > 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class DrainOutcome:
|
|
141
|
+
"""What the bounded wait for in-flight drives found (trap 2)."""
|
|
142
|
+
|
|
143
|
+
drained: bool
|
|
144
|
+
elapsed_seconds: float
|
|
145
|
+
detail: str = ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class WindowRecord:
|
|
150
|
+
"""One nightly-window attempt, start to finish, as journalled.
|
|
151
|
+
|
|
152
|
+
Deliberately shaped like `release_propagate.PropagationRecord`: same
|
|
153
|
+
append-only-JSONL-one-object-per-attempt journal, same reason — this
|
|
154
|
+
record must survive a half-installed venv and be readable with `tail`
|
|
155
|
+
while the very upgrade it describes is in flight.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
started_at: float
|
|
159
|
+
target_version: str | None = None
|
|
160
|
+
daemon_host: str | None = None
|
|
161
|
+
daemon_version: str | None = None
|
|
162
|
+
status: str = STATUS_ERROR
|
|
163
|
+
queue_timer: str = DEFAULT_QUEUE_TIMER
|
|
164
|
+
queue_stopped: bool | None = None
|
|
165
|
+
queue_stop_detail: str = ""
|
|
166
|
+
drained: bool | None = None
|
|
167
|
+
drain_seconds: float | None = None
|
|
168
|
+
drain_detail: str = ""
|
|
169
|
+
queue_restarted: bool | None = None
|
|
170
|
+
queue_restart_detail: str = ""
|
|
171
|
+
propagate_status: str | None = None
|
|
172
|
+
propagate_exit_code: int | None = None
|
|
173
|
+
propagate_output: str = ""
|
|
174
|
+
finished_at: float | None = None
|
|
175
|
+
error: str | None = None
|
|
176
|
+
dry_run: bool = False
|
|
177
|
+
|
|
178
|
+
def to_dict(self) -> dict:
|
|
179
|
+
return asdict(self)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def ok(self) -> bool:
|
|
183
|
+
return self.status in OK_STATUSES
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── the journal ──────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
#: Filename under the coord state root (`~/.coord` on Linux — see
|
|
189
|
+
#: `coord.platform_paths.default_coord_dir`). Separate from
|
|
190
|
+
#: `release_propagate.JOURNAL_NAME`: this record carries fields (queue
|
|
191
|
+
#: stop/drain/restart) a plain propagate attempt does not have, and
|
|
192
|
+
#: conflating the two would make either journal's shape a lie about the
|
|
193
|
+
#: other.
|
|
194
|
+
JOURNAL_NAME = "release_window.jsonl"
|
|
195
|
+
|
|
196
|
+
#: Records kept when the journal is trimmed. One per night, so this is
|
|
197
|
+
#: years of history — small enough to `cat`, generous enough that "when did
|
|
198
|
+
#: this last actually roll something" never scrolls off.
|
|
199
|
+
JOURNAL_MAX_RECORDS = 2000
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def journal_path(state_dir: Path) -> Path:
|
|
203
|
+
return Path(state_dir) / JOURNAL_NAME
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def append_record(state_dir: Path, record: WindowRecord) -> Path:
|
|
207
|
+
"""Append *record* as one JSON line. Best effort by contract.
|
|
208
|
+
|
|
209
|
+
A window run must never fail *because* it could not write its own
|
|
210
|
+
diary — but a silently-unwritten diary is the exact 2026-08-04/#2082
|
|
211
|
+
shape, so the caller is told (the shell reports a write failure as a
|
|
212
|
+
warning and still exits on the real outcome, same as
|
|
213
|
+
`release_propagate.append_record`).
|
|
214
|
+
"""
|
|
215
|
+
path = journal_path(state_dir)
|
|
216
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
218
|
+
fh.write(json.dumps(record.to_dict(), sort_keys=True) + "\n")
|
|
219
|
+
return path
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def read_records(state_dir: Path, *, limit: int | None = None) -> list[dict]:
|
|
223
|
+
"""Most-recent-last records from the journal; unparseable lines skipped.
|
|
224
|
+
|
|
225
|
+
A torn final line (the process killed mid-append — see #2112 acceptance
|
|
226
|
+
4) must not make the whole history unreadable.
|
|
227
|
+
"""
|
|
228
|
+
path = journal_path(state_dir)
|
|
229
|
+
if not path.exists():
|
|
230
|
+
return []
|
|
231
|
+
out: list[dict] = []
|
|
232
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
233
|
+
line = line.strip()
|
|
234
|
+
if not line:
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
obj = json.loads(line)
|
|
238
|
+
except ValueError:
|
|
239
|
+
continue
|
|
240
|
+
if isinstance(obj, dict):
|
|
241
|
+
out.append(obj)
|
|
242
|
+
if limit is not None and limit >= 0:
|
|
243
|
+
out = out[-limit:]
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def trim_journal(state_dir: Path, *, keep: int = JOURNAL_MAX_RECORDS) -> int:
|
|
248
|
+
"""Truncate the journal to its last *keep* records. Returns records kept."""
|
|
249
|
+
records = read_records(state_dir)
|
|
250
|
+
if len(records) <= keep:
|
|
251
|
+
return len(records)
|
|
252
|
+
kept = records[-keep:]
|
|
253
|
+
path = journal_path(state_dir)
|
|
254
|
+
path.write_text(
|
|
255
|
+
"".join(json.dumps(r, sort_keys=True) + "\n" for r in kept), encoding="utf-8"
|
|
256
|
+
)
|
|
257
|
+
return len(kept)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ── rendering ────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _stamp(ts: float | None) -> str:
|
|
264
|
+
if not ts:
|
|
265
|
+
return "?"
|
|
266
|
+
import datetime as _dt # noqa: PLC0415 — leaf import, keeps the module light
|
|
267
|
+
|
|
268
|
+
return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_STATUS_MARK = {
|
|
272
|
+
STATUS_UP_TO_DATE: "=",
|
|
273
|
+
STATUS_DRY_RUN: "·",
|
|
274
|
+
STATUS_ROLLED: "✓",
|
|
275
|
+
STATUS_DRAIN_TIMEOUT: "⏱",
|
|
276
|
+
STATUS_PROPAGATE_DEFERRED: "~",
|
|
277
|
+
STATUS_PROPAGATE_FAILED: "✗",
|
|
278
|
+
STATUS_ERROR: "✗",
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def render_record(record: WindowRecord | Mapping[str, Any]) -> list[str]:
|
|
283
|
+
"""Human-readable lines for one attempt — what `coord release
|
|
284
|
+
window-history` and the command's own stdout print."""
|
|
285
|
+
data = record.to_dict() if isinstance(record, WindowRecord) else dict(record)
|
|
286
|
+
status = str(data.get("status") or "?")
|
|
287
|
+
mark = _STATUS_MARK.get(status, "?")
|
|
288
|
+
version = data.get("target_version") or "?"
|
|
289
|
+
prefix = "[dry-run] " if data.get("dry_run") else ""
|
|
290
|
+
lines = [
|
|
291
|
+
f"{mark} {prefix}{_stamp(data.get('started_at'))} v{version} {status}"
|
|
292
|
+
]
|
|
293
|
+
if data.get("daemon_host"):
|
|
294
|
+
lines.append(
|
|
295
|
+
f" daemon host: {data['daemon_host']} "
|
|
296
|
+
f"(was v{data.get('daemon_version') or '?'})"
|
|
297
|
+
)
|
|
298
|
+
if data.get("queue_stopped") is not None:
|
|
299
|
+
lines.append(
|
|
300
|
+
f" {data.get('queue_timer')}: stopped="
|
|
301
|
+
f"{data['queue_stopped']} ({data.get('queue_stop_detail') or '-'})"
|
|
302
|
+
)
|
|
303
|
+
if data.get("drained") is not None:
|
|
304
|
+
secs = data.get("drain_seconds")
|
|
305
|
+
verdict = "clean" if data["drained"] else "TIMED OUT"
|
|
306
|
+
suffix = f" after {secs:.0f}s" if secs is not None else ""
|
|
307
|
+
lines.append(f" drain: {verdict}{suffix}")
|
|
308
|
+
if data.get("drain_detail"):
|
|
309
|
+
lines.append(f" {data['drain_detail']}")
|
|
310
|
+
if data.get("propagate_status"):
|
|
311
|
+
lines.append(
|
|
312
|
+
f" propagate: {data['propagate_status']} "
|
|
313
|
+
f"(exit {data.get('propagate_exit_code')})"
|
|
314
|
+
)
|
|
315
|
+
if data.get("queue_restarted") is not None:
|
|
316
|
+
lines.append(
|
|
317
|
+
f" {data.get('queue_timer')}: restarted="
|
|
318
|
+
f"{data['queue_restarted']} ({data.get('queue_restart_detail') or '-'})"
|
|
319
|
+
)
|
|
320
|
+
if data.get("error"):
|
|
321
|
+
lines.append(f" error: {data['error']}")
|
|
322
|
+
return lines
|