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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. 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
+ )