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/failure_class.py
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
"""Environmental-vs-work failure classification, resume scheduling, and the
|
|
2
|
+
pre-relaunch liveness gate (#1590).
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS. A terminal failure has two very different causes that today
|
|
5
|
+
look identical to every consumer:
|
|
6
|
+
|
|
7
|
+
* **work** — the tests failed, the review rejected the branch, no commits were
|
|
8
|
+
produced, the worker errored out. Retrying unchanged is pointless; the
|
|
9
|
+
right end state is BLOCKED, in front of a human.
|
|
10
|
+
* **environmental** — the Claude API returned 529/overloaded, the account hit
|
|
11
|
+
its usage limit mid-session, the network dropped. The work is untouched and
|
|
12
|
+
probably fine; the right end state is "wait for the weather, then resume",
|
|
13
|
+
and it must never consume the work-attempt budget.
|
|
14
|
+
|
|
15
|
+
Three environmental hits currently BLOCK a node whose code is perfectly fine,
|
|
16
|
+
and record *"drive died 3x without closing the issue"* — which sends the next
|
|
17
|
+
person looking at the work instead of the weather.
|
|
18
|
+
|
|
19
|
+
WHAT THIS MODULE IS. Three primitives, in dependency order:
|
|
20
|
+
|
|
21
|
+
1. :func:`classify_failure` (plus the log/result-event conveniences) — the
|
|
22
|
+
load-bearing decision. Everything else consumes it.
|
|
23
|
+
2. :func:`plan_usage_limit_resume` — turn the ``reset_at_raw`` that
|
|
24
|
+
:class:`coord.worker_events.UsageLimitKill` already parses (and currently
|
|
25
|
+
throws away) into an absolute *resume at* instant.
|
|
26
|
+
3. :func:`probe_environment_liveness` / :func:`gate_relaunch` — don't spend a
|
|
27
|
+
retry into a service that is still down; back off with a ceiling in the
|
|
28
|
+
tens of minutes, not 60s.
|
|
29
|
+
|
|
30
|
+
THE TAXONOMY IS DELIBERATELY LOPSIDED. Misclassifying a genuine work failure
|
|
31
|
+
as ``environmental`` means it retries forever instead of surfacing — strictly
|
|
32
|
+
worse than the bug this fixes. So:
|
|
33
|
+
|
|
34
|
+
* ``environmental`` requires a **positive, specific** signal from an explicit
|
|
35
|
+
allow-list (usage limit / 5xx-or-429 API status / overloaded / a named
|
|
36
|
+
transport error). There is no catch-all branch.
|
|
37
|
+
* Everything else — including "we have no idea" — is ``work``.
|
|
38
|
+
* The text-scanning patterns are anchored on wire-format tokens
|
|
39
|
+
(``api_error_status``, ``overloaded_error``, ``ECONNRESET``) that cannot
|
|
40
|
+
plausibly appear in a coordinator-authored work-failure summary. A bare
|
|
41
|
+
``529`` or the word "overloaded" is **not** a signal.
|
|
42
|
+
|
|
43
|
+
FEED IT TERMINAL SUMMARIES, NOT TRANSCRIPTS. ``failure_reason`` /
|
|
44
|
+
``terminal_reason`` are short strings the coordinator wrote; they are scanned
|
|
45
|
+
unconditionally. ``result_text`` is worker-authored prose and is scanned
|
|
46
|
+
**only** when ``is_error`` is truthy — otherwise a worker that merely
|
|
47
|
+
*discusses* an outage (this issue's own transcript, for instance) would
|
|
48
|
+
classify as environmental. Never pass a whole log tail as ``result_text``;
|
|
49
|
+
use :func:`classify_log`, which pulls the last ``result`` event for you.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import re
|
|
55
|
+
from dataclasses import dataclass
|
|
56
|
+
from datetime import datetime, timedelta, timezone
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from typing import TYPE_CHECKING, Callable
|
|
59
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
60
|
+
|
|
61
|
+
#1710 inventory — these four stay as direct ``coord.worker_events`` imports
|
|
62
|
+
# rather than routed through a ``Provider``:
|
|
63
|
+
# * ``is_usage_limit_reason``/``USAGE_LIMIT_REASON_PREFIX`` are a trivial
|
|
64
|
+
# string-prefix predicate over an already-derived ``failure_reason`` value,
|
|
65
|
+
# not a log-format parse (mirrors the identical note in ``coord.notify``).
|
|
66
|
+
# * ``detect_usage_limit_kill``/``detect_usage_limit_kill_in_log`` scan for
|
|
67
|
+
# the literal ``claude`` CLI's own subscription "You've hit your session
|
|
68
|
+
# limit" wording — inherently claude-specific business text with no
|
|
69
|
+
# generic equivalent; ``Capabilities``/``WorkerSummary`` don't model a
|
|
70
|
+
# generic "usage limit kill" concept, and inventing one is out of scope
|
|
71
|
+
# (non-goal: rewriting ``coord.worker_events``).
|
|
72
|
+
# ``classify_log`` (below) is the one function here that DOES read a
|
|
73
|
+
# provider's own log shape, and it routes through ``provider.parse_log()``.
|
|
74
|
+
from coord.worker_events import (
|
|
75
|
+
detect_usage_limit_kill,
|
|
76
|
+
detect_usage_limit_kill_in_log,
|
|
77
|
+
is_usage_limit_reason,
|
|
78
|
+
USAGE_LIMIT_REASON_PREFIX,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
from coord.providers.base import Provider
|
|
83
|
+
|
|
84
|
+
# ── the two classes ─────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
ENVIRONMENTAL = "environmental"
|
|
87
|
+
WORK = "work"
|
|
88
|
+
|
|
89
|
+
# Environmental sub-kinds. Stable strings — downstream (the sequencer's
|
|
90
|
+
# `environment-degraded` bookkeeping) may key off them.
|
|
91
|
+
KIND_USAGE_LIMIT = "usage_limit"
|
|
92
|
+
KIND_API_ERROR = "api_error"
|
|
93
|
+
KIND_NETWORK = "network"
|
|
94
|
+
KIND_WORK = "work"
|
|
95
|
+
|
|
96
|
+
#: HTTP statuses from the Claude API that mean "the provider, not the work".
|
|
97
|
+
#: An explicit allow-list, never a range test on "not 2xx": 4xx other than 429
|
|
98
|
+
#: (400 bad request, 401/403 auth, 404) are *our* bug or *our* config and must
|
|
99
|
+
#: surface, not retry forever.
|
|
100
|
+
ENVIRONMENTAL_API_STATUSES = frozenset({429, *range(500, 600)})
|
|
101
|
+
|
|
102
|
+
#: ``coord.network`` classified states that mean the transport, not the work.
|
|
103
|
+
#: ``HTTP_ERROR`` and ``UNKNOWN`` are deliberately absent — an HTTP 400 from an
|
|
104
|
+
#: agent server is a coordinator bug and must surface.
|
|
105
|
+
ENVIRONMENTAL_NETWORK_STATES = frozenset(
|
|
106
|
+
{"timeout", "dns_error", "offline", "rate_limited"}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── backoff / park tuning ───────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
#: First environmental backoff step. Matches ``concurrency.backoff_base`` so
|
|
113
|
+
#: the two knobs read the same, but see the ceiling below for why this alone is
|
|
114
|
+
#: not enough.
|
|
115
|
+
DEFAULT_BACKOFF_BASE_SECS = 60.0
|
|
116
|
+
|
|
117
|
+
#: Ceiling on the environmental backoff. The issue is explicit: three retries
|
|
118
|
+
#: at a 60s base covers a blip, not a provider incident. 20 minutes of
|
|
119
|
+
#: degradation is well within normal, so the ceiling belongs in the *tens of
|
|
120
|
+
#: minutes*. The probe that gates each attempt costs ~700ms and zero tokens,
|
|
121
|
+
#: so a 30-minute wait between probes is nearly free to hold.
|
|
122
|
+
DEFAULT_BACKOFF_CEILING_SECS = 1800.0
|
|
123
|
+
|
|
124
|
+
#: How long to park a usage-limit kill whose reset time we could not parse.
|
|
125
|
+
#: The 5-hour window's worst case is 5h, but a blind re-probe after an hour is
|
|
126
|
+
#: cheap and self-correcting (the relaunch is gated on a live probe anyway), so
|
|
127
|
+
#: this errs toward re-checking too early rather than sleeping through the
|
|
128
|
+
#: reset.
|
|
129
|
+
DEFAULT_USAGE_LIMIT_PARK_SECS = 3600.0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── classification ──────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class FailureClassification:
|
|
137
|
+
"""Why a terminal failure happened, in the only two flavours that change
|
|
138
|
+
what the coordinator should do next.
|
|
139
|
+
|
|
140
|
+
``failure_class`` is the load-bearing field (:data:`ENVIRONMENTAL` or
|
|
141
|
+
:data:`WORK`); ``kind`` narrows an environmental hit to the sub-kind that
|
|
142
|
+
decides *how* to wait. ``reason`` is the human-facing text — it always
|
|
143
|
+
names the class in words, so a blocked/paused row read at 3am points at
|
|
144
|
+
the right thing.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
failure_class: str
|
|
148
|
+
kind: str
|
|
149
|
+
reason: str
|
|
150
|
+
signal: str | None = None
|
|
151
|
+
api_status: int | None = None
|
|
152
|
+
reset_at_raw: str | None = None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def is_environmental(self) -> bool:
|
|
156
|
+
return self.failure_class == ENVIRONMENTAL
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def is_work(self) -> bool:
|
|
160
|
+
return self.failure_class == WORK
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def is_usage_limit(self) -> bool:
|
|
164
|
+
return self.kind == KIND_USAGE_LIMIT
|
|
165
|
+
|
|
166
|
+
def to_dict(self) -> dict:
|
|
167
|
+
return {
|
|
168
|
+
"failure_class": self.failure_class,
|
|
169
|
+
"kind": self.kind,
|
|
170
|
+
"reason": self.reason,
|
|
171
|
+
"signal": self.signal,
|
|
172
|
+
"api_status": self.api_status,
|
|
173
|
+
"reset_at_raw": self.reset_at_raw,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def counts_against_work_budget(classification: FailureClassification) -> bool:
|
|
178
|
+
"""Should this failure increment the node's *work*-attempt counter?
|
|
179
|
+
|
|
180
|
+
The one-liner the sequencer consumes (#1590 part 2, wired outside this
|
|
181
|
+
repo): only a work failure spends the budget that leads to BLOCKED, so a
|
|
182
|
+
node that has never had a work failure can never reach it.
|
|
183
|
+
"""
|
|
184
|
+
return classification.is_work
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Wire-format tokens for a Claude API server error. Anchored on the literal
|
|
188
|
+
# field/type names the CLI and the SDK emit, so a work-failure summary that
|
|
189
|
+
# happens to contain a three-digit number never matches.
|
|
190
|
+
#
|
|
191
|
+
# Deliberately NOT here: a generic `status: 5xx` / `"status":529` match. A
|
|
192
|
+
# worker fixing an HTTP handler legitimately reports "expected status: 200, got
|
|
193
|
+
# status: 503" as a *test* failure, and parking that node forever is worse than
|
|
194
|
+
# the bug this module fixes. The anthropic error body that would carry a bare
|
|
195
|
+
# status also carries `overloaded_error`/`api_error`, which `_API_TOKEN_RE`
|
|
196
|
+
# catches, so nothing real is lost.
|
|
197
|
+
_API_STATUS_RES = (
|
|
198
|
+
re.compile(r"api_error_status[\"'\s]*[:=]\s*[\"']?(\d{3})", re.IGNORECASE),
|
|
199
|
+
re.compile(r"\bAPI\s+Error:?\s*(\d{3})\b", re.IGNORECASE),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
_API_TOKEN_RE = re.compile(
|
|
203
|
+
r"\b(overloaded_error|rate_limit_error|api_error|internal_server_error)\b",
|
|
204
|
+
re.IGNORECASE,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Named transport failures. Every one of these is a token an OS, libc, undici
|
|
208
|
+
# or httpx emits verbatim — none is a phrase a coordinator would write about a
|
|
209
|
+
# failing test suite.
|
|
210
|
+
_NETWORK_RE = re.compile(
|
|
211
|
+
r"\b("
|
|
212
|
+
r"ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH"
|
|
213
|
+
r"|Connection\s+reset\s+by\s+peer"
|
|
214
|
+
r"|Temporary\s+failure\s+in\s+name\s+resolution"
|
|
215
|
+
r"|Name\s+or\s+service\s+not\s+known"
|
|
216
|
+
r"|[Nn]etwork\s+is\s+unreachable"
|
|
217
|
+
r"|Connection\s+error"
|
|
218
|
+
r"|fetch\s+failed"
|
|
219
|
+
r"|socket\s+hang\s+up"
|
|
220
|
+
r")\b"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# How much worker-authored terminal prose to consider. An error `result` is a
|
|
224
|
+
# one-liner plus a JSON blob; anything past this is a transcript that should
|
|
225
|
+
# not have been passed here in the first place.
|
|
226
|
+
_RESULT_TEXT_LIMIT = 4000
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _scan_api_status(text: str) -> int | None:
|
|
230
|
+
"""First allow-listed API status found in *text*, else ``None``.
|
|
231
|
+
|
|
232
|
+
A status that parses but is *not* in :data:`ENVIRONMENTAL_API_STATUSES`
|
|
233
|
+
(e.g. 400, 403) is not a match — it must surface as a work/config failure,
|
|
234
|
+
not retry into eternity.
|
|
235
|
+
"""
|
|
236
|
+
for pattern in _API_STATUS_RES:
|
|
237
|
+
for m in pattern.finditer(text):
|
|
238
|
+
try:
|
|
239
|
+
status = int(m.group(1))
|
|
240
|
+
except (TypeError, ValueError): # pragma: no cover - regex is \d{3}
|
|
241
|
+
continue
|
|
242
|
+
if status in ENVIRONMENTAL_API_STATUSES:
|
|
243
|
+
return status
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _environmental_api(text: str) -> tuple[int | None, str] | None:
|
|
248
|
+
"""``(status, signal)`` if *text* carries an API-server-error signal."""
|
|
249
|
+
status = _scan_api_status(text)
|
|
250
|
+
if status is not None:
|
|
251
|
+
return status, f"api_error_status={status}"
|
|
252
|
+
m = _API_TOKEN_RE.search(text)
|
|
253
|
+
if m:
|
|
254
|
+
return None, m.group(1).lower()
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _environmental_network(text: str) -> str | None:
|
|
259
|
+
"""The matched transport-error token, if *text* carries one."""
|
|
260
|
+
m = _NETWORK_RE.search(text)
|
|
261
|
+
return m.group(1) if m else None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _work(detail: str | None = None) -> FailureClassification:
|
|
265
|
+
suffix = f": {detail.strip()}" if detail and detail.strip() else (
|
|
266
|
+
": no environmental signal in the terminal state"
|
|
267
|
+
)
|
|
268
|
+
return FailureClassification(
|
|
269
|
+
failure_class=WORK,
|
|
270
|
+
kind=KIND_WORK,
|
|
271
|
+
reason=f"work failure{suffix}",
|
|
272
|
+
signal=None,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _usage_limit(reset_at_raw: str | None, signal: str) -> FailureClassification:
|
|
277
|
+
where = f", resets {reset_at_raw}" if reset_at_raw else ""
|
|
278
|
+
return FailureClassification(
|
|
279
|
+
failure_class=ENVIRONMENTAL,
|
|
280
|
+
kind=KIND_USAGE_LIMIT,
|
|
281
|
+
reason=(
|
|
282
|
+
"environmental (usage limit): the account's session budget was "
|
|
283
|
+
f"exhausted mid-run{where} — the provider's budget, not the work"
|
|
284
|
+
),
|
|
285
|
+
signal=signal,
|
|
286
|
+
reset_at_raw=reset_at_raw,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _reset_at_from_reason(reason: str | None) -> str | None:
|
|
291
|
+
"""Pull the raw reset string back out of a stamped ``failure_reason``."""
|
|
292
|
+
if not is_usage_limit_reason(reason):
|
|
293
|
+
return None
|
|
294
|
+
assert reason is not None # is_usage_limit_reason implies truthy
|
|
295
|
+
raw = reason[len(USAGE_LIMIT_REASON_PREFIX):].strip()
|
|
296
|
+
return raw or None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def classify_failure(
|
|
300
|
+
*,
|
|
301
|
+
failure_reason: str | None = None,
|
|
302
|
+
terminal_reason: str | None = None,
|
|
303
|
+
usage_limit_reason: str | None = None,
|
|
304
|
+
api_error_status: int | None = None,
|
|
305
|
+
network_error_state: str | None = None,
|
|
306
|
+
is_error: bool | None = None,
|
|
307
|
+
result_text: str | None = None,
|
|
308
|
+
) -> FailureClassification:
|
|
309
|
+
"""Classify a terminal failure as :data:`ENVIRONMENTAL` or :data:`WORK`.
|
|
310
|
+
|
|
311
|
+
Every parameter is an independent *evidence channel*; pass whichever ones
|
|
312
|
+
the call site has. Precedence runs most-specific-first, because the
|
|
313
|
+
remediation differs: a usage limit must wait for a reset time, an API 5xx
|
|
314
|
+
must wait for a liveness probe.
|
|
315
|
+
|
|
316
|
+
1. ``usage_limit_reason`` / ``failure_reason`` carrying the
|
|
317
|
+
:data:`coord.worker_events.USAGE_LIMIT_REASON_PREFIX` — exact prefix
|
|
318
|
+
match, no heuristics.
|
|
319
|
+
2. ``api_error_status`` in :data:`ENVIRONMENTAL_API_STATUSES`.
|
|
320
|
+
3. ``network_error_state`` in :data:`ENVIRONMENTAL_NETWORK_STATES` (the
|
|
321
|
+
states :func:`coord.network.classify_error` returns).
|
|
322
|
+
4. An anchored wire-token match in ``failure_reason`` / ``terminal_reason``
|
|
323
|
+
(always scanned — coordinator-authored summaries) or in ``result_text``
|
|
324
|
+
(scanned **only** when ``is_error`` is truthy — see the module
|
|
325
|
+
docstring).
|
|
326
|
+
|
|
327
|
+
Anything that matches none of those is :data:`WORK`, including the case
|
|
328
|
+
where no evidence was supplied at all. ``is_error=True`` on its own is not
|
|
329
|
+
an environmental signal — it is exactly as consistent with a failing test
|
|
330
|
+
suite.
|
|
331
|
+
"""
|
|
332
|
+
# 1. usage limit — exact, and takes precedence over everything: the reset
|
|
333
|
+
# time is the only remediation that matters, and re-dispatching onto a
|
|
334
|
+
# different machine burns the same account-wide budget.
|
|
335
|
+
for candidate in (usage_limit_reason, failure_reason, terminal_reason):
|
|
336
|
+
if is_usage_limit_reason(candidate):
|
|
337
|
+
return _usage_limit(_reset_at_from_reason(candidate), "usage_limit_reason")
|
|
338
|
+
|
|
339
|
+
# 2. an explicitly-reported API status.
|
|
340
|
+
if api_error_status is not None:
|
|
341
|
+
try:
|
|
342
|
+
status = int(api_error_status)
|
|
343
|
+
except (TypeError, ValueError):
|
|
344
|
+
status = None
|
|
345
|
+
if status is not None and status in ENVIRONMENTAL_API_STATUSES:
|
|
346
|
+
return FailureClassification(
|
|
347
|
+
failure_class=ENVIRONMENTAL,
|
|
348
|
+
kind=KIND_API_ERROR,
|
|
349
|
+
reason=(
|
|
350
|
+
f"environmental (Claude API {status}): the provider "
|
|
351
|
+
"returned a server error — not a defect in the work"
|
|
352
|
+
),
|
|
353
|
+
signal=f"api_error_status={status}",
|
|
354
|
+
api_status=status,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# 3. an explicitly-classified transport failure.
|
|
358
|
+
if network_error_state and str(network_error_state).lower() in ENVIRONMENTAL_NETWORK_STATES:
|
|
359
|
+
state = str(network_error_state).lower()
|
|
360
|
+
return FailureClassification(
|
|
361
|
+
failure_class=ENVIRONMENTAL,
|
|
362
|
+
kind=KIND_NETWORK,
|
|
363
|
+
reason=(
|
|
364
|
+
f"environmental (network {state}): the transport failed — "
|
|
365
|
+
"not a defect in the work"
|
|
366
|
+
),
|
|
367
|
+
signal=f"network_error_state={state}",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# 4. anchored token scan. Coordinator-authored summaries always; the
|
|
371
|
+
# worker's own terminal prose only when the result really was an error.
|
|
372
|
+
haystacks: list[str] = [t for t in (failure_reason, terminal_reason) if t]
|
|
373
|
+
if is_error and result_text:
|
|
374
|
+
haystacks.append(result_text[:_RESULT_TEXT_LIMIT])
|
|
375
|
+
|
|
376
|
+
for text in haystacks:
|
|
377
|
+
# Usage-limit kill message embedded in prose (the CLI's own wording,
|
|
378
|
+
# not our stamped prefix) — same remediation as branch 1.
|
|
379
|
+
kill = detect_usage_limit_kill(text)
|
|
380
|
+
if kill is not None:
|
|
381
|
+
return _usage_limit(kill.reset_at_raw, "usage limit kill message")
|
|
382
|
+
|
|
383
|
+
api = _environmental_api(text)
|
|
384
|
+
if api is not None:
|
|
385
|
+
status, signal = api
|
|
386
|
+
label = f"Claude API {status}" if status is not None else f"Claude API {signal}"
|
|
387
|
+
return FailureClassification(
|
|
388
|
+
failure_class=ENVIRONMENTAL,
|
|
389
|
+
kind=KIND_API_ERROR,
|
|
390
|
+
reason=(
|
|
391
|
+
f"environmental ({label}): the provider returned a server "
|
|
392
|
+
"error — not a defect in the work"
|
|
393
|
+
),
|
|
394
|
+
signal=signal,
|
|
395
|
+
api_status=status,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
token = _environmental_network(text)
|
|
399
|
+
if token is not None:
|
|
400
|
+
return FailureClassification(
|
|
401
|
+
failure_class=ENVIRONMENTAL,
|
|
402
|
+
kind=KIND_NETWORK,
|
|
403
|
+
reason=(
|
|
404
|
+
f"environmental (network {token}): the transport failed — "
|
|
405
|
+
"not a defect in the work"
|
|
406
|
+
),
|
|
407
|
+
signal=token,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
detail = next((t for t in (failure_reason, terminal_reason) if t), None)
|
|
411
|
+
return _work(detail)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def classify_result_event(raw: dict | None) -> FailureClassification:
|
|
415
|
+
"""Classify a stream-json terminal ``result`` event's raw payload.
|
|
416
|
+
|
|
417
|
+
Reads ``is_error``, ``subtype``, and the prose ``result`` / ``error``
|
|
418
|
+
fields. A ``result`` event with ``is_error`` falsy classifies :data:`WORK`
|
|
419
|
+
— a *successful* run is not this function's problem, and callers should not
|
|
420
|
+
be asking; returning WORK keeps the "default to work" invariant intact.
|
|
421
|
+
"""
|
|
422
|
+
if not isinstance(raw, dict):
|
|
423
|
+
return _work("no result event in the transcript")
|
|
424
|
+
is_error = bool(raw.get("is_error"))
|
|
425
|
+
subtype = raw.get("subtype")
|
|
426
|
+
text_parts = [
|
|
427
|
+
raw.get("result"),
|
|
428
|
+
raw.get("error"),
|
|
429
|
+
subtype if isinstance(subtype, str) else None,
|
|
430
|
+
]
|
|
431
|
+
result_text = "\n".join(p for p in text_parts if isinstance(p, str))
|
|
432
|
+
status = raw.get("api_error_status")
|
|
433
|
+
return classify_failure(
|
|
434
|
+
api_error_status=status if isinstance(status, int) else None,
|
|
435
|
+
is_error=is_error,
|
|
436
|
+
result_text=result_text or None,
|
|
437
|
+
terminal_reason=None,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def classify_log(
|
|
442
|
+
log_path: str | Path,
|
|
443
|
+
*,
|
|
444
|
+
failure_reason: str | None = None,
|
|
445
|
+
tail_bytes: int = 65536,
|
|
446
|
+
provider_name: str | None = None,
|
|
447
|
+
provider: "Provider | None" = None,
|
|
448
|
+
) -> FailureClassification:
|
|
449
|
+
"""Classify a worker's terminal state from its log.
|
|
450
|
+
|
|
451
|
+
Checks the usage-limit kill message (via
|
|
452
|
+
:func:`coord.worker_events.detect_usage_limit_kill_in_log`, which is
|
|
453
|
+
bounded to the transcript's literal last line — this is the literal
|
|
454
|
+
``claude`` CLI subscription-limit message, so it stays a direct
|
|
455
|
+
``coord.worker_events`` call regardless of provider; see the #1710
|
|
456
|
+
inventory) and the terminal state of the log, parsed via the
|
|
457
|
+
assignment's resolved :class:`~coord.providers.base.Provider`
|
|
458
|
+
(``provider.parse_log()`` — #1710) rather than assuming every log is
|
|
459
|
+
claude's stream-json shape. A missing/unreadable log with no
|
|
460
|
+
``failure_reason`` classifies :data:`WORK` — an unreadable log is not
|
|
461
|
+
evidence of an outage.
|
|
462
|
+
|
|
463
|
+
*provider_name* (typically ``Assignment.provider_name`` /
|
|
464
|
+
``IssueState.work_provider``) resolves via
|
|
465
|
+
:func:`coord.providers.get_provider`; ``None`` defaults to
|
|
466
|
+
:class:`~coord.providers.claude.ClaudeProvider`, matching pre-#1710
|
|
467
|
+
behaviour for every existing caller that doesn't pass it. *provider* is
|
|
468
|
+
an escape hatch for tests to pass an already-constructed provider
|
|
469
|
+
directly, bypassing name resolution.
|
|
470
|
+
|
|
471
|
+
#1710 NOTE — a documented, narrow behaviour difference from the
|
|
472
|
+
pre-#1710 implementation: that version additionally scanned the raw
|
|
473
|
+
``result`` event's ``error`` and bare ``subtype`` fields (via
|
|
474
|
+
:func:`classify_result_event`) for an environmental token/phrase when
|
|
475
|
+
``result`` itself carried none.
|
|
476
|
+
:class:`~coord.providers.base.WorkerSummary` (the ``parse_log()`` seam's
|
|
477
|
+
return shape) only carries ``result_text`` (``raw.get("result")``), not
|
|
478
|
+
``error``/``subtype`` — so a claude result event whose *only*
|
|
479
|
+
environmental signal lives in ``error`` or a bare ``subtype`` phrase
|
|
480
|
+
(no matching ``api_error_status``, no ``result`` text) would classify
|
|
481
|
+
differently here than before. No test in ``tests/test_failure_class.py``
|
|
482
|
+
exercises that narrow combination, and ``classify_log`` itself has no
|
|
483
|
+
production caller today — but this is exactly the "say so and stop for a
|
|
484
|
+
decision" case #1710 asks for if a fix proves ``WorkerSummary``'s shape
|
|
485
|
+
claude-specific. Flagged rather than silently accepted.
|
|
486
|
+
"""
|
|
487
|
+
if is_usage_limit_reason(failure_reason):
|
|
488
|
+
return _usage_limit(_reset_at_from_reason(failure_reason), "usage_limit_reason")
|
|
489
|
+
|
|
490
|
+
kill = detect_usage_limit_kill_in_log(log_path)
|
|
491
|
+
if kill is not None:
|
|
492
|
+
return _usage_limit(kill.reset_at_raw, "usage limit kill message")
|
|
493
|
+
|
|
494
|
+
if provider is None:
|
|
495
|
+
from coord.providers import get_provider # noqa: PLC0415
|
|
496
|
+
provider = get_provider(provider_name)
|
|
497
|
+
summary = provider.parse_log(log_path, tail_bytes=tail_bytes)
|
|
498
|
+
|
|
499
|
+
classification = classify_failure(
|
|
500
|
+
api_error_status=summary.api_error_status,
|
|
501
|
+
is_error=summary.is_error,
|
|
502
|
+
result_text=summary.result_text,
|
|
503
|
+
terminal_reason=None,
|
|
504
|
+
)
|
|
505
|
+
if classification.is_environmental:
|
|
506
|
+
return classification
|
|
507
|
+
|
|
508
|
+
return classify_failure(failure_reason=failure_reason)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ── resume from the captured reset time (#1590 part 3) ──────────────────────
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@dataclass(frozen=True)
|
|
515
|
+
class ResumePlan:
|
|
516
|
+
"""When an environmentally-parked node may be relaunched.
|
|
517
|
+
|
|
518
|
+
``resume_at`` is always set (timezone-aware): if the reset string could not
|
|
519
|
+
be parsed we fall back to ``failed_at + DEFAULT_USAGE_LIMIT_PARK_SECS``
|
|
520
|
+
rather than leaving the node parked forever — a park with no exit is the
|
|
521
|
+
bug this is fixing, one level down.
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
resume_at: datetime
|
|
525
|
+
parsed_from: str | None
|
|
526
|
+
reason: str
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def from_reset_time(self) -> bool:
|
|
530
|
+
"""True when ``resume_at`` came from the worker's own reset string."""
|
|
531
|
+
return self.parsed_from is not None
|
|
532
|
+
|
|
533
|
+
def seconds_remaining(self, *, now: datetime | None = None) -> float:
|
|
534
|
+
now = now or datetime.now(timezone.utc)
|
|
535
|
+
return max(0.0, (self.resume_at - now).total_seconds())
|
|
536
|
+
|
|
537
|
+
def due(self, *, now: datetime | None = None) -> bool:
|
|
538
|
+
now = now or datetime.now(timezone.utc)
|
|
539
|
+
return now >= self.resume_at
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
_TZ_RE = re.compile(r"\(([A-Za-z][A-Za-z_+\-]*(?:/[A-Za-z_+\-0-9]+)+|UTC|GMT)\)")
|
|
543
|
+
_MONTHS = {
|
|
544
|
+
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
|
|
545
|
+
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
|
|
546
|
+
}
|
|
547
|
+
_DATE_RE = re.compile(r"\b([A-Za-z]{3})[a-z]*\.?\s+(\d{1,2})\b")
|
|
548
|
+
_TIME_12_RE = re.compile(r"\b(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m\.?", re.IGNORECASE)
|
|
549
|
+
_TIME_24_RE = re.compile(r"\b(\d{1,2}):(\d{2})\b")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _resolve_zone(raw: str) -> tuple[object, str]:
|
|
553
|
+
"""``(tzinfo, remainder)`` — pull a ``(America/Chicago)`` suffix off *raw*.
|
|
554
|
+
|
|
555
|
+
Falls back to the host's local timezone when absent or unknown: the CLI
|
|
556
|
+
prints the reset in *some* wall-clock, and guessing UTC would silently
|
|
557
|
+
shift the resume by hours.
|
|
558
|
+
"""
|
|
559
|
+
local = datetime.now().astimezone().tzinfo or timezone.utc
|
|
560
|
+
m = _TZ_RE.search(raw)
|
|
561
|
+
if not m:
|
|
562
|
+
return local, raw
|
|
563
|
+
name = m.group(1)
|
|
564
|
+
remainder = (raw[: m.start()] + " " + raw[m.end():]).strip()
|
|
565
|
+
if name.upper() in ("UTC", "GMT"):
|
|
566
|
+
return timezone.utc, remainder
|
|
567
|
+
try:
|
|
568
|
+
return ZoneInfo(name), remainder
|
|
569
|
+
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
570
|
+
return local, remainder
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def parse_reset_at(raw: str | None, *, now: datetime | None = None) -> datetime | None:
|
|
574
|
+
"""Parse a ``reset_at_raw`` string into an absolute, aware ``datetime``.
|
|
575
|
+
|
|
576
|
+
Handles the shapes the CLI actually prints — ``"8:30pm (America/Chicago)"``,
|
|
577
|
+
``"Jul 27, 1:30am (America/Chicago)"``, ``"12pm"``, ``"20:30 (UTC)"``.
|
|
578
|
+
|
|
579
|
+
With no date component the reset is taken as the next occurrence of that
|
|
580
|
+
wall-clock time in its own timezone (so a "resets 8:30pm" seen at 9pm
|
|
581
|
+
means tomorrow, not twelve hours ago). With a month/day but no year, a
|
|
582
|
+
date that would land in the past rolls to next year.
|
|
583
|
+
|
|
584
|
+
Never raises: an unrecognisable string returns ``None``.
|
|
585
|
+
"""
|
|
586
|
+
if not raw or not isinstance(raw, str):
|
|
587
|
+
return None
|
|
588
|
+
now = now or datetime.now(timezone.utc)
|
|
589
|
+
tz, rest = _resolve_zone(raw)
|
|
590
|
+
local_now = now.astimezone(tz)
|
|
591
|
+
|
|
592
|
+
hour: int | None = None
|
|
593
|
+
minute = 0
|
|
594
|
+
m12 = _TIME_12_RE.search(rest)
|
|
595
|
+
if m12:
|
|
596
|
+
hour = int(m12.group(1))
|
|
597
|
+
minute = int(m12.group(2) or 0)
|
|
598
|
+
if hour == 12:
|
|
599
|
+
hour = 0
|
|
600
|
+
if m12.group(3).lower() == "p":
|
|
601
|
+
hour += 12
|
|
602
|
+
# Consume the time so the date scan can't re-read "1:30" as "Jan 30".
|
|
603
|
+
rest_wo_time = rest[: m12.start()] + " " + rest[m12.end():]
|
|
604
|
+
else:
|
|
605
|
+
m24 = _TIME_24_RE.search(rest)
|
|
606
|
+
if not m24:
|
|
607
|
+
return None
|
|
608
|
+
hour = int(m24.group(1))
|
|
609
|
+
minute = int(m24.group(2))
|
|
610
|
+
rest_wo_time = rest[: m24.start()] + " " + rest[m24.end():]
|
|
611
|
+
|
|
612
|
+
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
month: int | None = None
|
|
616
|
+
day: int | None = None
|
|
617
|
+
mdate = _DATE_RE.search(rest_wo_time)
|
|
618
|
+
if mdate:
|
|
619
|
+
month = _MONTHS.get(mdate.group(1).lower())
|
|
620
|
+
if month is not None:
|
|
621
|
+
day = int(mdate.group(2))
|
|
622
|
+
if not (1 <= day <= 31):
|
|
623
|
+
month = None
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
if month is not None and day is not None:
|
|
627
|
+
candidate = local_now.replace(
|
|
628
|
+
month=month, day=day, hour=hour, minute=minute,
|
|
629
|
+
second=0, microsecond=0,
|
|
630
|
+
)
|
|
631
|
+
if candidate < local_now - timedelta(days=1):
|
|
632
|
+
candidate = candidate.replace(year=candidate.year + 1)
|
|
633
|
+
else:
|
|
634
|
+
candidate = local_now.replace(
|
|
635
|
+
hour=hour, minute=minute, second=0, microsecond=0
|
|
636
|
+
)
|
|
637
|
+
if candidate <= local_now:
|
|
638
|
+
candidate += timedelta(days=1)
|
|
639
|
+
except ValueError:
|
|
640
|
+
# e.g. Feb 30 — a garbled string, not something to guess at.
|
|
641
|
+
return None
|
|
642
|
+
return candidate
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def plan_usage_limit_resume(
|
|
646
|
+
*,
|
|
647
|
+
failure_reason: str | None = None,
|
|
648
|
+
reset_at_raw: str | None = None,
|
|
649
|
+
failed_at: datetime | None = None,
|
|
650
|
+
now: datetime | None = None,
|
|
651
|
+
fallback_secs: float = DEFAULT_USAGE_LIMIT_PARK_SECS,
|
|
652
|
+
) -> ResumePlan:
|
|
653
|
+
"""Turn a usage-limit kill into an absolute resume instant.
|
|
654
|
+
|
|
655
|
+
``reset_at_raw`` wins if given; otherwise it is recovered from a
|
|
656
|
+
``failure_reason`` stamped by
|
|
657
|
+
:func:`coord.worker_events.format_usage_limit_reason`. When neither parses,
|
|
658
|
+
the node parks for *fallback_secs* from ``failed_at`` (default: now).
|
|
659
|
+
|
|
660
|
+
``failed_at`` is load-bearing for a bare wall-clock reset like
|
|
661
|
+
``"8:30pm"``: the reset is the next 8:30pm **after the kill**, not after
|
|
662
|
+
*now*. Anchoring on *now* would push an already-elapsed reset a full day
|
|
663
|
+
into the future every time the plan is recomputed, so callers that know
|
|
664
|
+
when the row failed must pass it. Without it we anchor on *now*, which errs
|
|
665
|
+
toward parking too long rather than relaunching into a live limit.
|
|
666
|
+
"""
|
|
667
|
+
now = now or datetime.now(timezone.utc)
|
|
668
|
+
raw = reset_at_raw or _reset_at_from_reason(failure_reason)
|
|
669
|
+
parsed = parse_reset_at(raw, now=failed_at or now)
|
|
670
|
+
if parsed is not None:
|
|
671
|
+
return ResumePlan(
|
|
672
|
+
resume_at=parsed,
|
|
673
|
+
parsed_from=raw,
|
|
674
|
+
reason=f"usage limit — resuming at the captured reset time ({raw})",
|
|
675
|
+
)
|
|
676
|
+
base = failed_at or now
|
|
677
|
+
detail = f" (unparseable reset time {raw!r})" if raw else " (no reset time captured)"
|
|
678
|
+
return ResumePlan(
|
|
679
|
+
resume_at=base + timedelta(seconds=fallback_secs),
|
|
680
|
+
parsed_from=None,
|
|
681
|
+
reason=(
|
|
682
|
+
f"usage limit — no usable reset time{detail}; re-checking in "
|
|
683
|
+
f"{int(fallback_secs // 60)}m"
|
|
684
|
+
),
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ── liveness probe + relaunch gate (#1590 part 4) ───────────────────────────
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def environmental_backoff_secs(
|
|
692
|
+
attempt: int,
|
|
693
|
+
*,
|
|
694
|
+
base: float = DEFAULT_BACKOFF_BASE_SECS,
|
|
695
|
+
ceiling: float = DEFAULT_BACKOFF_CEILING_SECS,
|
|
696
|
+
) -> float:
|
|
697
|
+
"""Exponential backoff for environmental retries, capped at *ceiling*.
|
|
698
|
+
|
|
699
|
+
*attempt* is 1-based (the first retry is attempt 1 and waits *base*).
|
|
700
|
+
Unlike ``concurrency.backoff_base``'s three-strikes-at-60s ladder, this is
|
|
701
|
+
designed to be held indefinitely: the cap is in the tens of minutes and
|
|
702
|
+
each wake-up costs one ~700ms, zero-token probe.
|
|
703
|
+
"""
|
|
704
|
+
if attempt < 1:
|
|
705
|
+
return 0.0
|
|
706
|
+
# Cap the exponent before the shift so a large attempt count can't build a
|
|
707
|
+
# multi-thousand-bit int on the way to being clamped.
|
|
708
|
+
exponent = min(attempt - 1, 32)
|
|
709
|
+
return min(ceiling, base * float(2 ** exponent))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@dataclass(frozen=True)
|
|
713
|
+
class LivenessResult:
|
|
714
|
+
"""Outcome of the pre-relaunch liveness probe.
|
|
715
|
+
|
|
716
|
+
``probed`` distinguishes "we asked and the service answered" from "we
|
|
717
|
+
could not ask" (no ``claude`` binary, API-key/Bedrock auth where ``/usage``
|
|
718
|
+
means nothing, unrecognised output). A probe we cannot trust must never
|
|
719
|
+
hold a relaunch, so an unprobeable environment reports
|
|
720
|
+
``alive=True, probed=False`` — fail-open, exactly like
|
|
721
|
+
:func:`coord.usage_limits.evaluate_usage_gate`.
|
|
722
|
+
"""
|
|
723
|
+
|
|
724
|
+
alive: bool
|
|
725
|
+
detail: str
|
|
726
|
+
probed: bool = True
|
|
727
|
+
|
|
728
|
+
def to_dict(self) -> dict:
|
|
729
|
+
return {"alive": self.alive, "detail": self.detail, "probed": self.probed}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _looks_like_outage(text: str | None) -> str | None:
|
|
733
|
+
"""The environmental signal in a probe's error/raw text, if any."""
|
|
734
|
+
if not text:
|
|
735
|
+
return None
|
|
736
|
+
api = _environmental_api(text)
|
|
737
|
+
if api is not None:
|
|
738
|
+
status, signal = api
|
|
739
|
+
return f"api {status}" if status is not None else signal
|
|
740
|
+
return _environmental_network(text)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def probe_environment_liveness(
|
|
744
|
+
*,
|
|
745
|
+
timeout: float = 15.0,
|
|
746
|
+
probe: Callable[[], object] | None = None,
|
|
747
|
+
) -> LivenessResult:
|
|
748
|
+
"""Is the Claude service answering right now?
|
|
749
|
+
|
|
750
|
+
Reuses the ``claude -p /usage`` probe the sequencer already runs
|
|
751
|
+
(:func:`coord.usage_limits.probe_plan_limits`): ~700ms, ``$0``, zero turns.
|
|
752
|
+
A parseable set of plan bars is proof of life. Anything else is triaged:
|
|
753
|
+
|
|
754
|
+
* the failure text carries an outage signal (5xx, overloaded, a named
|
|
755
|
+
transport error, or a hard timeout) → ``alive=False``;
|
|
756
|
+
* the failure is "this environment can't answer that question" (missing
|
|
757
|
+
binary, non-OAuth auth, unrecognised prose) → ``alive=True,
|
|
758
|
+
probed=False``, so the relaunch is not held hostage to a probe that will
|
|
759
|
+
never work here.
|
|
760
|
+
|
|
761
|
+
*probe* is an injection seam for tests; it must return a
|
|
762
|
+
:class:`coord.usage_limits.PlanLimits`-shaped object.
|
|
763
|
+
"""
|
|
764
|
+
if probe is None:
|
|
765
|
+
from coord.usage_limits import probe_plan_limits # noqa: PLC0415
|
|
766
|
+
|
|
767
|
+
def probe() -> object:
|
|
768
|
+
return probe_plan_limits(timeout=timeout)
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
limits = probe()
|
|
772
|
+
except Exception as exc: # noqa: BLE001 — a probe must never raise upward
|
|
773
|
+
return LivenessResult(
|
|
774
|
+
alive=True, detail=f"probe raised {type(exc).__name__}: {exc}", probed=False
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
if getattr(limits, "ok", False):
|
|
778
|
+
return LivenessResult(alive=True, detail="claude -p /usage answered with plan bars")
|
|
779
|
+
|
|
780
|
+
error = getattr(limits, "error", None) or ""
|
|
781
|
+
raw = getattr(limits, "raw", None) or ""
|
|
782
|
+
signal = _looks_like_outage(error) or _looks_like_outage(raw)
|
|
783
|
+
if signal:
|
|
784
|
+
return LivenessResult(alive=False, detail=f"claude -p /usage reports {signal}")
|
|
785
|
+
if "TimeoutExpired" in error:
|
|
786
|
+
return LivenessResult(alive=False, detail=f"claude -p /usage timed out: {error}")
|
|
787
|
+
return LivenessResult(
|
|
788
|
+
alive=True,
|
|
789
|
+
detail=f"probe inconclusive, not holding the relaunch: {error or 'no detail'}",
|
|
790
|
+
probed=False,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
@dataclass(frozen=True)
|
|
795
|
+
class RelaunchGate:
|
|
796
|
+
"""Whether to relaunch now, and if not, how long to wait and why."""
|
|
797
|
+
|
|
798
|
+
allow: bool
|
|
799
|
+
wait_secs: float
|
|
800
|
+
reason: str
|
|
801
|
+
classification: FailureClassification | None = None
|
|
802
|
+
liveness: LivenessResult | None = None
|
|
803
|
+
|
|
804
|
+
def to_dict(self) -> dict:
|
|
805
|
+
return {
|
|
806
|
+
"allow": self.allow,
|
|
807
|
+
"wait_secs": self.wait_secs,
|
|
808
|
+
"reason": self.reason,
|
|
809
|
+
"classification": (
|
|
810
|
+
self.classification.to_dict() if self.classification else None
|
|
811
|
+
),
|
|
812
|
+
"liveness": self.liveness.to_dict() if self.liveness else None,
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def gate_relaunch(
|
|
817
|
+
classification: FailureClassification,
|
|
818
|
+
*,
|
|
819
|
+
attempt: int = 1,
|
|
820
|
+
failed_at: datetime | None = None,
|
|
821
|
+
now: datetime | None = None,
|
|
822
|
+
probe: Callable[[], object] | None = None,
|
|
823
|
+
backoff_base: float = DEFAULT_BACKOFF_BASE_SECS,
|
|
824
|
+
backoff_ceiling: float = DEFAULT_BACKOFF_CEILING_SECS,
|
|
825
|
+
) -> RelaunchGate:
|
|
826
|
+
"""Decide whether an environmentally-failed node may be relaunched now.
|
|
827
|
+
|
|
828
|
+
* A :data:`WORK` failure is never gated here — it goes down the normal
|
|
829
|
+
bounded-retry / BLOCKED path, and this function says so without spending
|
|
830
|
+
a probe.
|
|
831
|
+
* A usage-limit kill waits for :func:`plan_usage_limit_resume`'s instant
|
|
832
|
+
first, then still has to pass the liveness probe. Pass ``failed_at`` —
|
|
833
|
+
it anchors the bare wall-clock reset string (see that function).
|
|
834
|
+
* An API/network failure waits out
|
|
835
|
+
:func:`environmental_backoff_secs` from ``failed_at``, then has to pass
|
|
836
|
+
the probe.
|
|
837
|
+
|
|
838
|
+
Runs at most one probe, and only when the clock already allows a relaunch —
|
|
839
|
+
a node parked until 8:30pm should not be probing every tick.
|
|
840
|
+
"""
|
|
841
|
+
now = now or datetime.now(timezone.utc)
|
|
842
|
+
|
|
843
|
+
if not classification.is_environmental:
|
|
844
|
+
return RelaunchGate(
|
|
845
|
+
allow=True,
|
|
846
|
+
wait_secs=0.0,
|
|
847
|
+
reason=(
|
|
848
|
+
"work failure — not gated on the environment; "
|
|
849
|
+
f"{classification.reason}"
|
|
850
|
+
),
|
|
851
|
+
classification=classification,
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
if classification.is_usage_limit:
|
|
855
|
+
plan = plan_usage_limit_resume(
|
|
856
|
+
reset_at_raw=classification.reset_at_raw,
|
|
857
|
+
failed_at=failed_at,
|
|
858
|
+
now=now,
|
|
859
|
+
)
|
|
860
|
+
if not plan.due(now=now):
|
|
861
|
+
remaining = plan.seconds_remaining(now=now)
|
|
862
|
+
return RelaunchGate(
|
|
863
|
+
allow=False,
|
|
864
|
+
wait_secs=remaining,
|
|
865
|
+
reason=(
|
|
866
|
+
f"{classification.reason}; parked for another "
|
|
867
|
+
f"{int(remaining // 60)}m — {plan.reason}"
|
|
868
|
+
),
|
|
869
|
+
classification=classification,
|
|
870
|
+
)
|
|
871
|
+
else:
|
|
872
|
+
wait = environmental_backoff_secs(
|
|
873
|
+
attempt, base=backoff_base, ceiling=backoff_ceiling
|
|
874
|
+
)
|
|
875
|
+
elapsed = (now - failed_at).total_seconds() if failed_at else wait
|
|
876
|
+
if elapsed < wait:
|
|
877
|
+
remaining = wait - elapsed
|
|
878
|
+
return RelaunchGate(
|
|
879
|
+
allow=False,
|
|
880
|
+
wait_secs=remaining,
|
|
881
|
+
reason=(
|
|
882
|
+
f"{classification.reason}; backing off "
|
|
883
|
+
f"{int(wait)}s from attempt {attempt} "
|
|
884
|
+
f"({int(remaining)}s remaining)"
|
|
885
|
+
),
|
|
886
|
+
classification=classification,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
liveness = probe_environment_liveness(probe=probe)
|
|
890
|
+
if liveness.alive:
|
|
891
|
+
return RelaunchGate(
|
|
892
|
+
allow=True,
|
|
893
|
+
wait_secs=0.0,
|
|
894
|
+
reason=(
|
|
895
|
+
f"{classification.reason}; liveness probe passed "
|
|
896
|
+
f"({liveness.detail}) — safe to relaunch"
|
|
897
|
+
),
|
|
898
|
+
classification=classification,
|
|
899
|
+
liveness=liveness,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
wait = environmental_backoff_secs(
|
|
903
|
+
attempt, base=backoff_base, ceiling=backoff_ceiling
|
|
904
|
+
)
|
|
905
|
+
return RelaunchGate(
|
|
906
|
+
allow=False,
|
|
907
|
+
wait_secs=wait,
|
|
908
|
+
reason=(
|
|
909
|
+
f"{classification.reason}; the service is still down "
|
|
910
|
+
f"({liveness.detail}) — waiting {int(wait)}s before probing again"
|
|
911
|
+
),
|
|
912
|
+
classification=classification,
|
|
913
|
+
liveness=liveness,
|
|
914
|
+
)
|