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/machine_pause.py
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
"""Persistent pause/resume state for machines (#routing-pause, #1563).
|
|
2
|
+
|
|
3
|
+
The pause set is a tiny JSON file at ``~/.coord/paused_machines.json``
|
|
4
|
+
holding ``{"paused": [<name>, ...]}``. Both the Python coordinator
|
|
5
|
+
(`coord plan`, `coord assign`, auto_loop, reconcile, review,
|
|
6
|
+
refine_chat) and the Rust TUI read it to decide whether a given
|
|
7
|
+
machine is a candidate for new work — paused machines stay reachable
|
|
8
|
+
and visible but never receive new assignments.
|
|
9
|
+
|
|
10
|
+
Pause does NOT cancel in-flight assignments; the user can `coord stop`
|
|
11
|
+
those separately if needed. This module only governs the routing
|
|
12
|
+
decision for *new* work.
|
|
13
|
+
|
|
14
|
+
#1563: pause is FLEET state, not host state. The autonomous dispatcher
|
|
15
|
+
(`coord serve`'s `_tick_loop` → `reconcile()` / `dispatch_pending_reviews()`
|
|
16
|
+
/ `auto_loop`) runs *inside the daemon*, which has no `board_service`
|
|
17
|
+
configured for itself — so it always reads the local JSON file below,
|
|
18
|
+
same as before. What used to be broken is a *thin client*: `coord pause`
|
|
19
|
+
run on an operator's laptop wrote to the laptop's own copy of this file,
|
|
20
|
+
which the daemon never saw. The public `paused_set()` / `pause()` /
|
|
21
|
+
`unpause()` below now check `coord.board_service.resolve()` first: when a
|
|
22
|
+
board service IS configured (thin client), they route over HTTP to the
|
|
23
|
+
daemon's `/pause` endpoint — which itself calls the same local-only
|
|
24
|
+
helpers here, so both the daemon's own tick loop and every thin client
|
|
25
|
+
end up reading/writing the *one* copy of this file that actually governs
|
|
26
|
+
dispatch. When no board service is configured (solo/local use, or the
|
|
27
|
+
daemon's own in-process calls), behaviour is unchanged: same file, same
|
|
28
|
+
atomic tempfile-rename writes.
|
|
29
|
+
|
|
30
|
+
`pause()`/`unpause()` (explicit user actions) fail LOUDLY on a thin
|
|
31
|
+
client — an HTTP/transport error propagates rather than reporting
|
|
32
|
+
success (#1563: "there is no configuration in which a thin-client pause
|
|
33
|
+
fails loudly. It always reports success and always fails open").
|
|
34
|
+
`paused_set()` (the read side, consulted on every dispatch decision)
|
|
35
|
+
stays fail-soft on a remote fetch error, consistent with this module's
|
|
36
|
+
existing local-read behaviour and every other daemon read-through helper
|
|
37
|
+
in `coord.client` (`fetch_issue_context`, `fetch_drive_escalations`, …):
|
|
38
|
+
a transient network blip degrades to "nothing is paused" rather than
|
|
39
|
+
wedging the dispatcher, matching the pre-existing contract documented
|
|
40
|
+
below for a malformed/missing local file.
|
|
41
|
+
|
|
42
|
+
#1862: per-machine quiet hours. `Machine.quiet_hours` (parsed by
|
|
43
|
+
`coord.config`) declares a recurring daily window during which a machine
|
|
44
|
+
should receive no NEW dispatch — the same routing-pause semantics as an
|
|
45
|
+
explicit `coord pause`, computed instead of stored. This module is the
|
|
46
|
+
single place that union happens: `paused_set()`/`local_paused_set()`
|
|
47
|
+
accept an optional `machines` sequence and, when given one, fold in
|
|
48
|
+
`{m.name for m in machines if m.quiet_hours.covers(now)}` — every one of
|
|
49
|
+
the eight call sites listed in #1862 already has its `Config` in local
|
|
50
|
+
scope, so they pass `config.machines` and inherit quiet hours with no
|
|
51
|
+
second routing check anywhere else. `machines=None` (the default)
|
|
52
|
+
degrades to "explicit pauses only", i.e. unchanged pre-#1862 behaviour —
|
|
53
|
+
this is what keeps every deployment with no `quiet_hours:` block, and
|
|
54
|
+
every caller not yet threading `machines` through, byte-identical to
|
|
55
|
+
before.
|
|
56
|
+
|
|
57
|
+
#2101: RELEASE CORDONS. A cordon is the same *routing* semantics as a pause —
|
|
58
|
+
no new agents route to that machine, in-flight work is untouched — set
|
|
59
|
+
automatically by `coord release propagate` so a host behind the released
|
|
60
|
+
version drains itself into a rollable state instead of waiting for a window
|
|
61
|
+
that never arrives. It shares `paused_set()` (every dispatcher already reads
|
|
62
|
+
it, so nothing else in the fleet needs to learn a second concept) and NOTHING
|
|
63
|
+
else:
|
|
64
|
+
|
|
65
|
+
* it lives under its own `release_cordons` key, with an `owner`, a `reason`,
|
|
66
|
+
a `created_at` and an `expires_at` — `local_pause`/`local_unpause` never
|
|
67
|
+
read or write it, and `set_cordon`/`clear_cordon` never touch `paused`.
|
|
68
|
+
An operator's `coord unpause` therefore cannot lift a cordon mid-drain, and
|
|
69
|
+
the post-roll uncordon cannot clear a pause an operator set deliberately
|
|
70
|
+
(#2101 trap A);
|
|
71
|
+
* it EXPIRES. The cordon lives in daemon state and the daemon is restarted
|
|
72
|
+
by the very roll it gates, so a propagate run killed between cordon and
|
|
73
|
+
uncordon would otherwise leave the fleet refusing work forever — which
|
|
74
|
+
looks exactly like a quiet fleet (#2082 in a new costume). The read side
|
|
75
|
+
(`local_cordons()`) simply ignores an expired record, so nothing has to run
|
|
76
|
+
for a cordon to lapse; the live loop renews on every run while the host is
|
|
77
|
+
still behind (#2101 trap B).
|
|
78
|
+
|
|
79
|
+
`coord unpause` during an active quiet window would otherwise be a lie
|
|
80
|
+
(#1563's failure class: reports success, changes nothing, the machine is
|
|
81
|
+
paused again on the very next read). `local_unpause_effective()` picks
|
|
82
|
+
the "explicit override" resolution named in #1862: unpausing a
|
|
83
|
+
quiet-covered, not-explicitly-paused machine records an override that
|
|
84
|
+
suppresses quiet hours until the CURRENT window's end (persisted
|
|
85
|
+
alongside the explicit-pause list below, under `quiet_overrides`) and
|
|
86
|
+
says so — never silently re-paused, never silently accepted as a no-op.
|
|
87
|
+
"""
|
|
88
|
+
from __future__ import annotations
|
|
89
|
+
|
|
90
|
+
import json
|
|
91
|
+
import os
|
|
92
|
+
import tempfile
|
|
93
|
+
from collections.abc import Mapping, Sequence
|
|
94
|
+
from dataclasses import dataclass
|
|
95
|
+
from datetime import datetime, timezone
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
from typing import TYPE_CHECKING
|
|
98
|
+
from zoneinfo import ZoneInfo
|
|
99
|
+
|
|
100
|
+
if TYPE_CHECKING:
|
|
101
|
+
from coord.models import Machine
|
|
102
|
+
|
|
103
|
+
_STATE_FILENAME = "paused_machines.json"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _state_path() -> Path:
|
|
107
|
+
"""Return the absolute path to the pause-state file.
|
|
108
|
+
|
|
109
|
+
Lives under ``$HOME/.coord/`` so it sits alongside the rest of the
|
|
110
|
+
runtime state (`assignments.db`, `agent_state.json`, etc.).
|
|
111
|
+
"""
|
|
112
|
+
home = Path(os.environ.get("HOME", "/tmp")).expanduser()
|
|
113
|
+
return home / ".coord" / _STATE_FILENAME
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def paused_set(
|
|
117
|
+
machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
|
|
118
|
+
) -> set[str]:
|
|
119
|
+
"""Read the current set of paused machine names (#1563: daemon-aware).
|
|
120
|
+
|
|
121
|
+
Routes through the daemon's `/pause` endpoint when a board service is
|
|
122
|
+
configured (thin client); otherwise reads the local file directly. The
|
|
123
|
+
remote fetch fails SOFT (any error → empty set) — see module docstring
|
|
124
|
+
for why reads stay fail-open while explicit `pause()`/`unpause()` calls
|
|
125
|
+
don't.
|
|
126
|
+
|
|
127
|
+
On a thin client *machines*/*now* are ignored — the daemon's own
|
|
128
|
+
`/pause` endpoint already folds its quiet-hours-covered machines into
|
|
129
|
+
the response (see `coord.serve_app.get_pause`), so the thin client's
|
|
130
|
+
view is correct without knowing about quiet hours itself. Pass
|
|
131
|
+
*machines* (almost always `config.machines`, already in scope at every
|
|
132
|
+
call site) to fold quiet hours into the LOCAL computation — the daemon's
|
|
133
|
+
own in-process tick-loop calls (no board service configured for
|
|
134
|
+
itself), and any solo/local use with no daemon at all.
|
|
135
|
+
"""
|
|
136
|
+
svc = _resolve_service()
|
|
137
|
+
if svc is not None:
|
|
138
|
+
from coord.client import fetch_paused_machines # noqa: PLC0415
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
return fetch_paused_machines(svc)
|
|
142
|
+
except Exception: # noqa: BLE001 — fail-soft read, see module docstring
|
|
143
|
+
return set()
|
|
144
|
+
return local_paused_set(machines, now=now)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_paused(
|
|
148
|
+
name: str, machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
|
|
149
|
+
) -> bool:
|
|
150
|
+
"""Convenience: True when *name* is in the paused set."""
|
|
151
|
+
return name in paused_set(machines, now=now)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def pause(name: str) -> bool:
|
|
155
|
+
"""Add *name* to the paused set. Returns True when the set changed
|
|
156
|
+
(i.e. *name* was not already paused).
|
|
157
|
+
|
|
158
|
+
#1563: on a thin client this POSTs to the daemon's `/pause` endpoint
|
|
159
|
+
and raises (`httpx.HTTPError`) if that can't be confirmed — a pause
|
|
160
|
+
that silently fails to reach the daemon is the exact failure mode this
|
|
161
|
+
module exists to close. Callers that must never raise (e.g. an
|
|
162
|
+
interactive command that wants to print a clean error) should catch
|
|
163
|
+
around this call.
|
|
164
|
+
"""
|
|
165
|
+
svc = _resolve_service()
|
|
166
|
+
if svc is not None:
|
|
167
|
+
from coord.client import post_pause # noqa: PLC0415
|
|
168
|
+
|
|
169
|
+
result = post_pause(svc, name, "pause")
|
|
170
|
+
return bool(result.get("changed"))
|
|
171
|
+
return local_pause(name)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class UnpauseOutcome:
|
|
176
|
+
"""Result of an #1862-aware unpause — see `local_unpause_effective()`.
|
|
177
|
+
|
|
178
|
+
`kind` is one of:
|
|
179
|
+
* ``"resumed"`` — an explicit `coord pause` was lifted.
|
|
180
|
+
* ``"quiet_override"`` — the machine wasn't explicitly paused but WAS
|
|
181
|
+
inside its quiet-hours window; an override now suppresses that
|
|
182
|
+
window until it would have ended anyway (`quiet_until`/`tz`).
|
|
183
|
+
* ``"not_paused"`` — genuinely not paused by any mechanism; a
|
|
184
|
+
true no-op, distinguishable from the two "did something" cases
|
|
185
|
+
above so a caller never reports success for nothing happening.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
changed: bool
|
|
189
|
+
kind: str
|
|
190
|
+
quiet_until: str | None = None # local "HH:MM" the override lasts until
|
|
191
|
+
tz: str | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def unpause(
|
|
195
|
+
name: str, machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
|
|
196
|
+
) -> UnpauseOutcome:
|
|
197
|
+
"""Resolve `coord unpause <name>` (#1862-aware). See `pause()` for the
|
|
198
|
+
thin-client routing / fail-loudly contract, and `UnpauseOutcome` for
|
|
199
|
+
what `.kind` means.
|
|
200
|
+
|
|
201
|
+
#1862: unpausing a machine that is inside its quiet-hours window but
|
|
202
|
+
was never explicitly paused must not silently no-op (the machine would
|
|
203
|
+
be paused again on the very next read — #1563's failure class) and
|
|
204
|
+
must not silently pretend to have lifted a pause that was never set.
|
|
205
|
+
Instead it records an explicit override for the remainder of that
|
|
206
|
+
window — see `local_unpause_effective()`.
|
|
207
|
+
"""
|
|
208
|
+
svc = _resolve_service()
|
|
209
|
+
if svc is not None:
|
|
210
|
+
from coord.client import post_pause # noqa: PLC0415
|
|
211
|
+
|
|
212
|
+
result = post_pause(svc, name, "unpause")
|
|
213
|
+
changed = bool(result.get("changed"))
|
|
214
|
+
return UnpauseOutcome(
|
|
215
|
+
changed=changed,
|
|
216
|
+
kind=result.get("kind") or ("resumed" if changed else "not_paused"),
|
|
217
|
+
quiet_until=result.get("quiet_until"),
|
|
218
|
+
tz=result.get("tz"),
|
|
219
|
+
)
|
|
220
|
+
return local_unpause_effective(name, machines, now=now)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _resolve_service(): # -> coord.client.ServiceConfig | None
|
|
224
|
+
from coord.board_service import resolve # noqa: PLC0415
|
|
225
|
+
|
|
226
|
+
return resolve()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── local-only (#1563: always used by the daemon's own `/pause` endpoint and
|
|
230
|
+
# tick loop, and by every caller when no board service is configured) ───────
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def local_paused_set(
|
|
234
|
+
machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
|
|
235
|
+
) -> set[str]:
|
|
236
|
+
"""The local, effective paused-machine set: explicit pauses UNION any
|
|
237
|
+
machine currently inside its quiet-hours window (#1862) UNION any machine
|
|
238
|
+
under an unexpired release cordon (#2101), minus any machine with an
|
|
239
|
+
active `coord unpause` override for that quiet window.
|
|
240
|
+
|
|
241
|
+
Omitting *machines* drops only the quiet-hours half — that is what keeps
|
|
242
|
+
every pre-#1862 caller (and `local_pause`/`local_unpause` below, which use
|
|
243
|
+
`_explicit_paused_set()` directly to test EXPLICIT membership)
|
|
244
|
+
byte-identical. Cordons need no config to resolve, so they are folded in
|
|
245
|
+
unconditionally: a cordon that only applied when the caller happened to
|
|
246
|
+
pass `config.machines` would be a cordon half the dispatchers ignore.
|
|
247
|
+
Malformed/missing local file degrades to "nothing paused" — failure to
|
|
248
|
+
read should never block routing.
|
|
249
|
+
|
|
250
|
+
Unlike `paused_set()`, this NEVER goes over the network — it is what
|
|
251
|
+
the daemon's `/pause` endpoint handler calls (passing `config.machines`
|
|
252
|
+
so quiet hours apply), and what `paused_set()` itself falls through to
|
|
253
|
+
when no board service is configured.
|
|
254
|
+
"""
|
|
255
|
+
effective = _explicit_paused_set() | cordoned_names(now=_epoch(now))
|
|
256
|
+
if not machines:
|
|
257
|
+
return effective
|
|
258
|
+
return effective | _quiet_covered_names(machines, now=now)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def local_pause(name: str) -> bool:
|
|
262
|
+
"""Add *name* to the local EXPLICIT paused set. Returns True when the
|
|
263
|
+
set changed (i.e. *name* was not already explicitly paused).
|
|
264
|
+
|
|
265
|
+
Deliberately orthogonal to quiet hours: an explicit pause is tracked
|
|
266
|
+
and reported independently of whatever a machine's `quiet_hours` window
|
|
267
|
+
happens to be doing at the same moment.
|
|
268
|
+
"""
|
|
269
|
+
current = _explicit_paused_set()
|
|
270
|
+
if name in current:
|
|
271
|
+
return False
|
|
272
|
+
current.add(name)
|
|
273
|
+
_save_state(paused=current)
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def local_unpause(name: str) -> bool:
|
|
278
|
+
"""Remove *name* from the local EXPLICIT paused set. Returns True when
|
|
279
|
+
the set changed (i.e. *name* was actually explicitly paused).
|
|
280
|
+
|
|
281
|
+
This is the pre-#1862 primitive: it only ever looks at explicit pauses,
|
|
282
|
+
so it correctly reports "not paused" (False) for a machine that is
|
|
283
|
+
merely inside its quiet-hours window — `local_unpause_effective()`
|
|
284
|
+
below is what gives THAT case a truthful, non-lying answer.
|
|
285
|
+
"""
|
|
286
|
+
current = _explicit_paused_set()
|
|
287
|
+
if name not in current:
|
|
288
|
+
return False
|
|
289
|
+
current.discard(name)
|
|
290
|
+
_save_state(paused=current)
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def local_unpause_effective(
|
|
295
|
+
name: str, machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
|
|
296
|
+
) -> UnpauseOutcome:
|
|
297
|
+
"""#1862: the truthful `coord unpause` — see `UnpauseOutcome` for the
|
|
298
|
+
three possible outcomes and the module docstring for why a bare
|
|
299
|
+
`local_unpause()` (explicit-only) would otherwise let `coord unpause`
|
|
300
|
+
report success and change nothing for a quiet-covered machine.
|
|
301
|
+
"""
|
|
302
|
+
if local_unpause(name):
|
|
303
|
+
return UnpauseOutcome(changed=True, kind="resumed")
|
|
304
|
+
|
|
305
|
+
now = now if now is not None else datetime.now(timezone.utc)
|
|
306
|
+
machine = next((m for m in (machines or ()) if m.name == name), None)
|
|
307
|
+
qh = machine.quiet_hours if machine is not None else None
|
|
308
|
+
if qh is not None and qh.covers(now):
|
|
309
|
+
until_utc = qh.window_end_instant(now)
|
|
310
|
+
_set_quiet_override(name, until_utc)
|
|
311
|
+
until_local = until_utc.astimezone(ZoneInfo(qh.tz))
|
|
312
|
+
return UnpauseOutcome(
|
|
313
|
+
changed=True,
|
|
314
|
+
kind="quiet_override",
|
|
315
|
+
quiet_until=until_local.strftime("%H:%M"),
|
|
316
|
+
tz=qh.tz,
|
|
317
|
+
)
|
|
318
|
+
return UnpauseOutcome(changed=False, kind="not_paused")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ── #1862: quiet-hours computation ──────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def quiet_paused_names(
|
|
325
|
+
machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
|
|
326
|
+
) -> set[str]:
|
|
327
|
+
"""Public: names of machines currently paused SPECIFICALLY because a
|
|
328
|
+
`quiet_hours` window covers *now* (never overridden — an active
|
|
329
|
+
`coord unpause` override excludes a machine from this set, same as it
|
|
330
|
+
excludes it from `local_paused_set()`'s union).
|
|
331
|
+
|
|
332
|
+
Always a subset of `local_paused_set(machines, now=now)`. Review finding
|
|
333
|
+
on #1862's original PR: `coord status`'s `describe_pause_state()`
|
|
334
|
+
distinguished a quiet-paused machine from a hand-paused one, but the
|
|
335
|
+
daemon's `/pause` endpoint and the TUI sidebar badge did not — this is
|
|
336
|
+
the choke point both now call so a machine "asleep until 08:00" reads
|
|
337
|
+
differently from one someone explicitly paused, everywhere pause state
|
|
338
|
+
is displayed, not just `coord status`.
|
|
339
|
+
"""
|
|
340
|
+
if not machines:
|
|
341
|
+
return set()
|
|
342
|
+
return _quiet_covered_names(machines, now=now)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _quiet_covered_names(
|
|
346
|
+
machines: Sequence["Machine"], *, now: datetime | None = None,
|
|
347
|
+
) -> set[str]:
|
|
348
|
+
"""Names of machines whose `quiet_hours` window covers *now*, excluding
|
|
349
|
+
any with an active `coord unpause` override for that window."""
|
|
350
|
+
now = now if now is not None else datetime.now(timezone.utc)
|
|
351
|
+
overridden = _active_quiet_override_names(now)
|
|
352
|
+
return {
|
|
353
|
+
m.name
|
|
354
|
+
for m in machines
|
|
355
|
+
if m.quiet_hours is not None
|
|
356
|
+
and m.name not in overridden
|
|
357
|
+
and m.quiet_hours.covers(now)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _active_quiet_override_names(now: datetime | None = None) -> set[str]:
|
|
362
|
+
now = now if now is not None else datetime.now(timezone.utc)
|
|
363
|
+
active: set[str] = set()
|
|
364
|
+
for name, raw_until in _quiet_overrides().items():
|
|
365
|
+
try:
|
|
366
|
+
until = datetime.fromisoformat(raw_until)
|
|
367
|
+
except ValueError:
|
|
368
|
+
continue
|
|
369
|
+
if until.tzinfo is None:
|
|
370
|
+
until = until.replace(tzinfo=timezone.utc)
|
|
371
|
+
if now < until:
|
|
372
|
+
active.add(name)
|
|
373
|
+
return active
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _set_quiet_override(name: str, until_utc: datetime) -> None:
|
|
377
|
+
overrides = _quiet_overrides()
|
|
378
|
+
overrides[name] = until_utc.astimezone(timezone.utc).isoformat()
|
|
379
|
+
_save_state(quiet_overrides=overrides)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ── #2101: release cordons ───────────────────────────────────────────────────
|
|
383
|
+
#
|
|
384
|
+
# A THIRD, independent axis of this file (see the module docstring). The three
|
|
385
|
+
# never write each other's key: `paused` is the operator's, `quiet_overrides`
|
|
386
|
+
# is `coord unpause`'s, `release_cordons` is `coord release propagate`'s.
|
|
387
|
+
# `_save_state` preserves whichever two the caller didn't pass, which is the
|
|
388
|
+
# single line that makes "each clears only its own" true rather than merely
|
|
389
|
+
# intended.
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _epoch(now: "datetime | float | None") -> float | None:
|
|
393
|
+
"""Accept either clock form the callers in this module use.
|
|
394
|
+
|
|
395
|
+
Quiet hours are computed from an aware `datetime`; cordon expiry is an
|
|
396
|
+
epoch float (it is compared against `time.time()` stamps written by the
|
|
397
|
+
propagate shell). One helper rather than two clock conventions leaking
|
|
398
|
+
into every signature.
|
|
399
|
+
"""
|
|
400
|
+
if now is None:
|
|
401
|
+
return None
|
|
402
|
+
if isinstance(now, datetime):
|
|
403
|
+
return now.timestamp()
|
|
404
|
+
return float(now)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _now_epoch(now: float | None = None) -> float:
|
|
408
|
+
return now if now is not None else datetime.now(timezone.utc).timestamp()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def local_cordons(*, now: float | None = None, include_expired: bool = False) -> dict:
|
|
412
|
+
"""``{machine: Cordon}`` — the local cordon store.
|
|
413
|
+
|
|
414
|
+
Expired records are dropped unless *include_expired*: a cordon lapses by
|
|
415
|
+
being IGNORED, never by being cleaned up, so a propagate run that died
|
|
416
|
+
mid-drain (or a daemon that was restarted by the very roll it was gating)
|
|
417
|
+
cannot leave the fleet cordoned forever — trap B of #2101. Nothing has to
|
|
418
|
+
run for that to hold.
|
|
419
|
+
"""
|
|
420
|
+
from coord.release_cordon import Cordon # noqa: PLC0415 — leaf import
|
|
421
|
+
|
|
422
|
+
when = _now_epoch(now)
|
|
423
|
+
out: dict[str, Cordon] = {}
|
|
424
|
+
for name, raw in _cordon_records().items():
|
|
425
|
+
record = Cordon.from_dict({**raw, "machine": raw.get("machine") or name})
|
|
426
|
+
if include_expired or record.active(when):
|
|
427
|
+
out[name] = record
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cordoned_names(*, now: float | None = None) -> set[str]:
|
|
432
|
+
"""Names of machines under an unexpired release cordon.
|
|
433
|
+
|
|
434
|
+
Always a subset of `local_paused_set()`; fail-soft on an unreadable store,
|
|
435
|
+
same contract as `_explicit_paused_set()`.
|
|
436
|
+
"""
|
|
437
|
+
try:
|
|
438
|
+
return set(local_cordons(now=now))
|
|
439
|
+
except Exception: # noqa: BLE001 — a bad store must never wedge routing
|
|
440
|
+
return set()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def local_set_cordon(
|
|
444
|
+
name: str,
|
|
445
|
+
*,
|
|
446
|
+
reason: str = "",
|
|
447
|
+
target_version: str | None = None,
|
|
448
|
+
ttl_seconds: float | None = None,
|
|
449
|
+
owner: str | None = None,
|
|
450
|
+
created_at: float | None = None,
|
|
451
|
+
now: float | None = None,
|
|
452
|
+
) -> object:
|
|
453
|
+
"""Write (or renew) *name*'s release cordon. Returns the stored `Cordon`.
|
|
454
|
+
|
|
455
|
+
Renewal preserves `created_at` — the drain deadline (#2101 trap C)
|
|
456
|
+
measures from the FIRST cordon of this drain, so a wedged host cannot
|
|
457
|
+
postpone its own escalation by being renewed every 20 minutes.
|
|
458
|
+
"""
|
|
459
|
+
from coord.release_cordon import ( # noqa: PLC0415
|
|
460
|
+
DEFAULT_TTL_SECONDS,
|
|
461
|
+
OWNER_RELEASE,
|
|
462
|
+
Cordon,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
when = _now_epoch(now)
|
|
466
|
+
ttl = DEFAULT_TTL_SECONDS if ttl_seconds is None else max(0.0, float(ttl_seconds))
|
|
467
|
+
existing = _cordon_records().get(name) or {}
|
|
468
|
+
try:
|
|
469
|
+
previous_created = float(existing.get("created_at") or 0.0)
|
|
470
|
+
except (TypeError, ValueError):
|
|
471
|
+
previous_created = 0.0
|
|
472
|
+
record = Cordon(
|
|
473
|
+
machine=name,
|
|
474
|
+
owner=owner or OWNER_RELEASE,
|
|
475
|
+
reason=reason,
|
|
476
|
+
target_version=target_version,
|
|
477
|
+
created_at=created_at or previous_created or when,
|
|
478
|
+
renewed_at=when,
|
|
479
|
+
expires_at=when + ttl,
|
|
480
|
+
)
|
|
481
|
+
records = _cordon_records()
|
|
482
|
+
records[name] = record.to_dict()
|
|
483
|
+
_save_state(cordons=records)
|
|
484
|
+
return record
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def local_clear_cordon(name: str, *, owner: str | None = None) -> bool:
|
|
488
|
+
"""Drop *name*'s cordon. True when one was actually there.
|
|
489
|
+
|
|
490
|
+
*owner* (when given) is enforced: an owner may only clear its own cordon.
|
|
491
|
+
An operator's `coord pause` is untouched either way — it is not stored
|
|
492
|
+
here at all.
|
|
493
|
+
"""
|
|
494
|
+
records = _cordon_records()
|
|
495
|
+
record = records.get(name)
|
|
496
|
+
if record is None:
|
|
497
|
+
return False
|
|
498
|
+
if owner is not None and str(record.get("owner") or "") != owner:
|
|
499
|
+
return False
|
|
500
|
+
del records[name]
|
|
501
|
+
_save_state(cordons=records)
|
|
502
|
+
return True
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def local_prune_cordons(*, now: float | None = None) -> list[str]:
|
|
506
|
+
"""Delete expired records and return their names.
|
|
507
|
+
|
|
508
|
+
Purely hygiene: `local_cordons()` already ignores them, so nothing
|
|
509
|
+
depends on this having run. It exists so the store does not accumulate
|
|
510
|
+
dead rows and so `coord release cordon --list` can report "these lapsed"
|
|
511
|
+
once rather than forever.
|
|
512
|
+
"""
|
|
513
|
+
when = _now_epoch(now)
|
|
514
|
+
live = set(local_cordons(now=when))
|
|
515
|
+
records = _cordon_records()
|
|
516
|
+
dropped = sorted(name for name in records if name not in live)
|
|
517
|
+
if dropped:
|
|
518
|
+
for name in dropped:
|
|
519
|
+
records.pop(name, None)
|
|
520
|
+
_save_state(cordons=records)
|
|
521
|
+
return dropped
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ── #2101: daemon-aware cordon read/write (same seam as pause/unpause) ───────
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def cordons(*, now: float | None = None) -> dict:
|
|
528
|
+
"""`{machine: Cordon}` from the daemon when one is configured (#1563's
|
|
529
|
+
seam), else the local store. Fail-soft on a remote error, exactly like
|
|
530
|
+
`paused_set()` — see the module docstring for why reads degrade to
|
|
531
|
+
"nothing is cordoned" while writes below fail loudly.
|
|
532
|
+
"""
|
|
533
|
+
svc = _resolve_service()
|
|
534
|
+
if svc is None:
|
|
535
|
+
return local_cordons(now=now)
|
|
536
|
+
from coord.client import fetch_cordons # noqa: PLC0415
|
|
537
|
+
from coord.release_cordon import Cordon # noqa: PLC0415
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
rows = fetch_cordons(svc)
|
|
541
|
+
except Exception: # noqa: BLE001 — fail-soft read, see module docstring
|
|
542
|
+
return {}
|
|
543
|
+
when = _now_epoch(now)
|
|
544
|
+
out: dict[str, Cordon] = {}
|
|
545
|
+
for raw in rows:
|
|
546
|
+
record = Cordon.from_dict(raw)
|
|
547
|
+
if record.machine and record.active(when):
|
|
548
|
+
out[record.machine] = record
|
|
549
|
+
return out
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def set_cordon(
|
|
553
|
+
name: str,
|
|
554
|
+
*,
|
|
555
|
+
reason: str = "",
|
|
556
|
+
target_version: str | None = None,
|
|
557
|
+
ttl_seconds: float | None = None,
|
|
558
|
+
) -> object:
|
|
559
|
+
"""Cordon *name*, daemon-first. Raises on a transport/HTTP failure.
|
|
560
|
+
|
|
561
|
+
A cordon that silently fails to reach the daemon is worse than no cordon
|
|
562
|
+
at all: `coord release propagate` would go on to restart agents believing
|
|
563
|
+
it had stopped new work, which is the in-flight-worker massacre the whole
|
|
564
|
+
quiescence design exists to prevent. So this fails LOUDLY, like
|
|
565
|
+
`pause()`/`unpause()` and unlike the read side.
|
|
566
|
+
"""
|
|
567
|
+
svc = _resolve_service()
|
|
568
|
+
if svc is None:
|
|
569
|
+
return local_set_cordon(
|
|
570
|
+
name,
|
|
571
|
+
reason=reason,
|
|
572
|
+
target_version=target_version,
|
|
573
|
+
ttl_seconds=ttl_seconds,
|
|
574
|
+
)
|
|
575
|
+
from coord.client import post_cordon # noqa: PLC0415
|
|
576
|
+
from coord.release_cordon import Cordon # noqa: PLC0415
|
|
577
|
+
|
|
578
|
+
result = post_cordon(
|
|
579
|
+
svc,
|
|
580
|
+
name,
|
|
581
|
+
"cordon",
|
|
582
|
+
reason=reason,
|
|
583
|
+
target_version=target_version,
|
|
584
|
+
ttl_seconds=ttl_seconds,
|
|
585
|
+
)
|
|
586
|
+
return Cordon.from_dict(result.get("cordon") or {"machine": name})
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def clear_cordon(name: str) -> bool:
|
|
590
|
+
"""Uncordon *name*, daemon-first. Raises on transport/HTTP failure."""
|
|
591
|
+
svc = _resolve_service()
|
|
592
|
+
if svc is None:
|
|
593
|
+
return local_clear_cordon(name)
|
|
594
|
+
from coord.client import post_cordon # noqa: PLC0415
|
|
595
|
+
|
|
596
|
+
return bool(post_cordon(svc, name, "uncordon").get("changed"))
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ── internals ────────────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _load_raw() -> dict:
|
|
603
|
+
path = _state_path()
|
|
604
|
+
if not path.exists():
|
|
605
|
+
return {}
|
|
606
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
607
|
+
return json.load(fh)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _explicit_paused_set() -> set[str]:
|
|
611
|
+
try:
|
|
612
|
+
data = _load_raw()
|
|
613
|
+
except (OSError, ValueError):
|
|
614
|
+
return set()
|
|
615
|
+
items = data.get("paused")
|
|
616
|
+
if not isinstance(items, list):
|
|
617
|
+
return set()
|
|
618
|
+
return {str(x) for x in items if isinstance(x, str) and x}
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _quiet_overrides() -> dict[str, str]:
|
|
622
|
+
try:
|
|
623
|
+
data = _load_raw()
|
|
624
|
+
except (OSError, ValueError):
|
|
625
|
+
return {}
|
|
626
|
+
items = data.get("quiet_overrides")
|
|
627
|
+
if not isinstance(items, dict):
|
|
628
|
+
return {}
|
|
629
|
+
return {str(k): str(v) for k, v in items.items() if isinstance(k, str) and isinstance(v, str)}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _cordon_records() -> dict[str, dict]:
|
|
633
|
+
"""#2101: the raw `release_cordons` map, `{machine: record}`.
|
|
634
|
+
|
|
635
|
+
Malformed entries are dropped individually rather than failing the whole
|
|
636
|
+
read — this is consulted on every dispatch decision in the fleet.
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
data = _load_raw()
|
|
640
|
+
except (OSError, ValueError):
|
|
641
|
+
return {}
|
|
642
|
+
items = data.get("release_cordons")
|
|
643
|
+
if not isinstance(items, dict):
|
|
644
|
+
return {}
|
|
645
|
+
return {
|
|
646
|
+
str(k): dict(v)
|
|
647
|
+
for k, v in items.items()
|
|
648
|
+
if isinstance(k, str) and k and isinstance(v, dict)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _save_state(
|
|
653
|
+
*,
|
|
654
|
+
paused: set[str] | None = None,
|
|
655
|
+
quiet_overrides: dict[str, str] | None = None,
|
|
656
|
+
cordons: dict[str, dict] | None = None,
|
|
657
|
+
) -> None:
|
|
658
|
+
"""Read-modify-write the local state file, preserving whichever axes the
|
|
659
|
+
caller doesn't pass.
|
|
660
|
+
|
|
661
|
+
Explicit pauses, quiet-hours overrides and release cordons (#2101) are
|
|
662
|
+
three INDEPENDENT axes with three different owners; a write to one must
|
|
663
|
+
never clobber another. This one function is what makes #2101 trap A
|
|
664
|
+
("each must clear only its own") true rather than merely intended, so
|
|
665
|
+
every new key added here must be preserved the same way.
|
|
666
|
+
"""
|
|
667
|
+
path = _state_path()
|
|
668
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
669
|
+
new_paused = sorted(paused) if paused is not None else sorted(_explicit_paused_set())
|
|
670
|
+
new_overrides = quiet_overrides if quiet_overrides is not None else _quiet_overrides()
|
|
671
|
+
new_cordons = cordons if cordons is not None else _cordon_records()
|
|
672
|
+
payload = {
|
|
673
|
+
"paused": new_paused,
|
|
674
|
+
"quiet_overrides": dict(sorted(new_overrides.items())),
|
|
675
|
+
"release_cordons": {k: dict(v) for k, v in sorted(new_cordons.items())},
|
|
676
|
+
}
|
|
677
|
+
# Atomic write: tempfile in the same dir then rename so a crashed
|
|
678
|
+
# writer can never leave a partially-written file in place.
|
|
679
|
+
fd, tmp = tempfile.mkstemp(prefix=".paused_machines.", suffix=".tmp", dir=path.parent)
|
|
680
|
+
try:
|
|
681
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
682
|
+
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
683
|
+
fh.write("\n")
|
|
684
|
+
os.replace(tmp, path)
|
|
685
|
+
except Exception:
|
|
686
|
+
try:
|
|
687
|
+
os.unlink(tmp)
|
|
688
|
+
except OSError:
|
|
689
|
+
pass
|
|
690
|
+
raise
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# ── #1862: pause-state display (`coord status`, and any future consumer
|
|
694
|
+
# that wants to distinguish a hand pause from a quiet-hours one) ────────────
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@dataclass(frozen=True)
|
|
698
|
+
class PauseState:
|
|
699
|
+
"""Why a machine is (or isn't, but would be if not overridden) paused,
|
|
700
|
+
for display purposes only — never consulted for routing.
|
|
701
|
+
|
|
702
|
+
`kind` is one of:
|
|
703
|
+
* ``"hand"`` — an explicit `coord pause`.
|
|
704
|
+
* ``"quiet"`` — inside its quiet-hours window right now.
|
|
705
|
+
* ``"quiet_overridden"`` — inside its quiet-hours window, but a
|
|
706
|
+
`coord unpause` override is currently suppressing it (so it is NOT
|
|
707
|
+
in the effective paused set, and IS dispatchable).
|
|
708
|
+
* ``"cordon"`` — a #2101 release cordon: this machine is
|
|
709
|
+
draining so it can be rolled onto the released version. It will lift
|
|
710
|
+
itself the moment the roll lands (or expire on its own if the run
|
|
711
|
+
that set it died), so it must NOT read as "someone paused this".
|
|
712
|
+
"""
|
|
713
|
+
|
|
714
|
+
kind: str
|
|
715
|
+
detail: str
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def describe_pause_state(
|
|
719
|
+
machine: "Machine",
|
|
720
|
+
paused: set[str],
|
|
721
|
+
*,
|
|
722
|
+
now: datetime | None = None,
|
|
723
|
+
cordons: "Mapping[str, object] | None" = None,
|
|
724
|
+
) -> PauseState | None:
|
|
725
|
+
"""Derive *machine*'s pause state purely from *paused* (the already-
|
|
726
|
+
fetched effective set from `paused_set()`) plus the machine's own
|
|
727
|
+
locally-known `quiet_hours` config — no extra network round trip.
|
|
728
|
+
|
|
729
|
+
*cordons* is the already-fetched `{machine: Cordon}` map (#2101). Passing
|
|
730
|
+
it is what lets a caller say "cordoned: draining for v0.5.31" instead of
|
|
731
|
+
the flatly wrong "PAUSED": a cordon is not an operator's decision and is
|
|
732
|
+
not cleared by `coord unpause`, so rendering the two identically is the
|
|
733
|
+
"work stopped and nobody said why" failure #2101 trap E names. Omitting
|
|
734
|
+
it degrades to the pre-#2101 rendering.
|
|
735
|
+
|
|
736
|
+
Returns ``None`` when the machine isn't paused by any mechanism and
|
|
737
|
+
isn't in an overridden quiet window either.
|
|
738
|
+
"""
|
|
739
|
+
covered = machine.quiet_hours is not None and machine.quiet_hours.covers(now)
|
|
740
|
+
in_paused = machine.name in paused
|
|
741
|
+
cordon = (cordons or {}).get(machine.name)
|
|
742
|
+
if cordon is not None:
|
|
743
|
+
# Checked FIRST: a cordoned machine is in `paused` (that is how
|
|
744
|
+
# routing honours it), so every branch below would otherwise claim it.
|
|
745
|
+
describe = getattr(cordon, "describe", None)
|
|
746
|
+
return PauseState(kind="cordon", detail=describe() if callable(describe) else str(cordon))
|
|
747
|
+
if in_paused and covered:
|
|
748
|
+
qh = machine.quiet_hours
|
|
749
|
+
assert qh is not None # covered implies quiet_hours is set
|
|
750
|
+
return PauseState(kind="quiet", detail=f"until {qh.end.strftime('%H:%M')} ({qh.tz})")
|
|
751
|
+
if in_paused:
|
|
752
|
+
return PauseState(kind="hand", detail="")
|
|
753
|
+
if covered:
|
|
754
|
+
return PauseState(kind="quiet_overridden", detail="override active — dispatchable")
|
|
755
|
+
return None
|