shikiru-cli 0.2.0__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 (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,219 @@
1
+ """Quota wait wording: the block's clock text, the provider reset and the engine retry note."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import timedelta
7
+ from enum import StrEnum
8
+ from operator import itemgetter
9
+ from typing import TYPE_CHECKING, Protocol
10
+
11
+ from shikiru.agents.contracts import (
12
+ QUOTA_WINDOW_FIVE_HOUR,
13
+ QUOTA_WINDOW_SEVEN_DAY,
14
+ FailureKind,
15
+ is_exhausted,
16
+ )
17
+ from shikiru.domain.events import EventKind
18
+ from shikiru.domain.recovery import quota_block_until
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterable, Sequence
22
+ from datetime import datetime
23
+
24
+ from shikiru.agents.contracts import QuotaWindow
25
+ from shikiru.domain.events import Event
26
+ from shikiru.domain.tasks import Role, Task
27
+
28
+ QUOTA_BLOCKED_PHRASE = "quota blocked until"
29
+ QUOTA_RETRY_PHRASE = "retry at"
30
+ QUOTA_WORD = "quota"
31
+ ENGINE_RETRY_DIFFERS = "Engine retry differs from the {window} reset."
32
+ QUOTA_TODAY_WORD = "Today"
33
+ QUOTA_NOT_REPORTED = "not reported"
34
+ QUOTA_WINDOW_LABELS = {QUOTA_WINDOW_FIVE_HOUR: "5-hour", QUOTA_WINDOW_SEVEN_DAY: "weekly"}
35
+ QUOTA_WAIT_CLOCK_FORMAT = "%H:%M"
36
+ QUOTA_WAIT_DATE_FORMAT = "%b %d"
37
+ QUOTA_WAIT_ZERO_OFFSET_SUFFIX = "Z"
38
+ QUOTA_UNDER_A_MINUTE = "<1m"
39
+ QUOTA_BANNER_SEPARATOR = " · "
40
+ QUOTA_WAITING_SUFFIX = "waiting"
41
+ QUOTA_TASK_NOUN = "task"
42
+ QUOTA_TASKS_NOUN = "tasks"
43
+ QUOTA_TURN_LIFECYCLE = (
44
+ EventKind.STEP_STARTED,
45
+ EventKind.TURN_STARTED,
46
+ EventKind.TURN_COMPLETED,
47
+ EventKind.TURN_FAILED,
48
+ EventKind.TURN_STOPPED,
49
+ EventKind.RUNG_CHANGED,
50
+ )
51
+ _SECONDS_PER_MINUTE = 60
52
+ _MINUTES_PER_HOUR = 60
53
+ _HOURS_PER_DAY = 24
54
+ _MINUTES_PER_DAY = _HOURS_PER_DAY * _MINUTES_PER_HOUR
55
+
56
+
57
+ class QuotaAction(StrEnum):
58
+ """What the engine does at the time a quota wait shows: probe for newer evidence or retry."""
59
+
60
+ NEXT_CHECK = "next check"
61
+ RETRY = "retry"
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ScheduledQuotaAction:
66
+ """The engine's next action on the quota block set at `blocked_at`, and when it happens."""
67
+
68
+ action: QuotaAction
69
+ at: datetime
70
+ blocked_at: datetime
71
+
72
+
73
+ def quota_reset_clock(until: datetime, now: datetime) -> str:
74
+ """Return when a block lifts in the zone `until` and `now` carry, as the viewer's local time.
75
+
76
+ The date is shown when the reset falls on another local day than `now`; `Z` marks only a
77
+ zero offset, so a non-UTC local time never carries it.
78
+ """
79
+ clock = until.strftime(QUOTA_WAIT_CLOCK_FORMAT)
80
+ if until.date() != now.date():
81
+ clock = f"{until.strftime(QUOTA_WAIT_DATE_FORMAT)} {clock}"
82
+ suffix = QUOTA_WAIT_ZERO_OFFSET_SUFFIX if until.utcoffset() == timedelta(0) else ""
83
+ return f"{clock}{suffix}"
84
+
85
+
86
+ def provider_reset_text(resets_at: datetime, now: datetime) -> str:
87
+ """Return a provider's reset as `Today` and a clock on today's local day, else as a clock."""
88
+ clock = quota_reset_clock(resets_at, now)
89
+ if resets_at.date() == now.date():
90
+ return f"{QUOTA_TODAY_WORD} {clock}"
91
+ return clock
92
+
93
+
94
+ def quota_window_label(name: str) -> str:
95
+ """Return the words a quota window is named by, such as `5-hour` for `5h`."""
96
+ return QUOTA_WINDOW_LABELS.get(name, name)
97
+
98
+
99
+ def quota_wait_description(agent: str, until: datetime, now: datetime) -> str:
100
+ """Name the blocked `agent` and when its block lifts, in the zone `until` and `now` carry."""
101
+ return f"{agent} {QUOTA_BLOCKED_PHRASE} {quota_reset_clock(until, now)}"
102
+
103
+
104
+ def quota_banner(description: str, waiting_tasks: int) -> str:
105
+ """Append how many tasks wait behind the block to its `description`, when any do."""
106
+ if waiting_tasks == 0:
107
+ return description
108
+ noun = QUOTA_TASK_NOUN if waiting_tasks == 1 else QUOTA_TASKS_NOUN
109
+ return f"{description}{QUOTA_BANNER_SEPARATOR}{waiting_tasks} {noun} {QUOTA_WAITING_SUFFIX}"
110
+
111
+
112
+ def quota_remaining(until: datetime, now: datetime) -> str:
113
+ """Return the whole minutes left until a block lifts, as `1d 2h 3m`, `2h 3m` or `3m`."""
114
+ minutes = max(int((until - now).total_seconds()), 0) // _SECONDS_PER_MINUTE
115
+ if minutes == 0:
116
+ return QUOTA_UNDER_A_MINUTE
117
+ days, minutes = divmod(minutes, _MINUTES_PER_DAY)
118
+ hours, minutes = divmod(minutes, _MINUTES_PER_HOUR)
119
+ if days:
120
+ return f"{days}d {hours}h {minutes}m"
121
+ if hours:
122
+ return f"{hours}h {minutes}m"
123
+ return f"{minutes}m"
124
+
125
+
126
+ def quota_retry_description(agent: str, until: datetime, now: datetime) -> str:
127
+ """Name the `agent` quota and the engine's retry, in the zone `until` carries."""
128
+ return (
129
+ f"{agent} {QUOTA_WORD}{QUOTA_BANNER_SEPARATOR}{QUOTA_RETRY_PHRASE} "
130
+ f"{quota_reset_clock(until, now)}"
131
+ )
132
+
133
+
134
+ def quota_clock_text(agent: str, until: datetime, now: datetime) -> tuple[str, str]:
135
+ """Return the clock-driven wording of a quota wait: its retry description and its time left."""
136
+ return quota_retry_description(agent, until, now), quota_remaining(until, now)
137
+
138
+
139
+ def engine_retry_note(retry: datetime, windows: Sequence[QuotaWindow], now: datetime) -> str | None:
140
+ """Explain how the engine's retry differs from the provider reset it follows, when it does.
141
+
142
+ The engine follows the latest pending reset of the windows reported exhausted, plus its margin.
143
+ A retry on that instant needs no note; any other retry is named against that reset. Without a
144
+ pending exhausted reset, no reset is known to compare with, so nothing is said.
145
+ """
146
+ pending = [
147
+ (window.resets_at, window.name)
148
+ for window in windows
149
+ if is_exhausted(window) and window.resets_at is not None and window.resets_at > now
150
+ ]
151
+ if not pending:
152
+ return None
153
+ latest_reset, window_name = max(pending, key=itemgetter(0))
154
+ if retry == quota_block_until(latest_reset, now):
155
+ return None
156
+ return ENGINE_RETRY_DIFFERS.format(window=quota_window_label(window_name))
157
+
158
+
159
+ def next_quota_reload(now: datetime, instants: Iterable[datetime]) -> datetime:
160
+ """Return the first of `instants` after `now`, the next moment a quota wait's data changes.
161
+
162
+ `instants` are the moments the block lifts, its reroute window closes and other blocks lift.
163
+ The wording moves with the clock on its own and never needs this reload.
164
+ """
165
+ return min(instant for instant in instants if instant > now)
166
+
167
+
168
+ class LatestEvents(Protocol):
169
+ """Reads the newest events of one task, in chronological order."""
170
+
171
+ def __call__(
172
+ self, limit: int, *, kinds: Sequence[EventKind] | None = None, task_id: str | None = None
173
+ ) -> list[Event]:
174
+ """Return up to `limit` of the newest events matching `kinds` and `task_id`."""
175
+ ...
176
+
177
+
178
+ def current_quota_wait_events(
179
+ task: Task, role: Role, agent: str, latest_events: LatestEvents
180
+ ) -> tuple[Event, Event] | None:
181
+ """Return the turn failure and quota block that put `task`'s current turn in a quota wait.
182
+
183
+ The wait is current only while the task's latest turn lifecycle event is a quota failure of
184
+ its current step, the turn that failed was `role`'s on `agent` in the current iteration, and
185
+ the latest quota event of the task is the block of that agent set after the failure.
186
+ """
187
+ if task.step is None:
188
+ return None
189
+ lifecycle = latest_events(1, kinds=QUOTA_TURN_LIFECYCLE, task_id=task.id)
190
+ if (
191
+ not lifecycle
192
+ or lifecycle[0].kind is not EventKind.TURN_FAILED
193
+ or lifecycle[0].data.get("kind") != FailureKind.QUOTA.value
194
+ or lifecycle[0].data.get("step") != task.step.value
195
+ ):
196
+ return None
197
+ failure = lifecycle[0]
198
+ starts = latest_events(1, kinds=(EventKind.TURN_STARTED,), task_id=task.id)
199
+ if not starts or starts[0].id >= failure.id:
200
+ return None
201
+ started = starts[0]
202
+ if (
203
+ started.data.get("step") != task.step.value
204
+ or started.data.get("iteration") != task.iteration
205
+ or started.data.get("role") != role.value
206
+ or started.data.get("agent") != agent
207
+ ):
208
+ return None
209
+ quota_events = latest_events(
210
+ 1, kinds=(EventKind.QUOTA_BLOCKED, EventKind.QUOTA_CLEARED), task_id=task.id
211
+ )
212
+ if (
213
+ not quota_events
214
+ or quota_events[0].kind is not EventKind.QUOTA_BLOCKED
215
+ or quota_events[0].id <= failure.id
216
+ or quota_events[0].data.get("agent") != agent
217
+ ):
218
+ return None
219
+ return failure, quota_events[0]
@@ -0,0 +1,194 @@
1
+ """Recovery decisions for every non-completed turn result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from datetime import datetime, timedelta
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.agents.contracts import FailureKind, exhausted_reset
10
+ from shikiru.domain.tasks import EscalationReason
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Sequence
14
+
15
+ from shikiru.agents.contracts import QuotaWindow
16
+ from shikiru.domain.reports import Report
17
+
18
+ MAX_STALL_RECOVERIES_PER_TURN = 2
19
+ OTHER_RETRIES_PER_TURN = 1
20
+ QUOTA_MARGIN = timedelta(seconds=60)
21
+ QUOTA_PROBE = timedelta(minutes=15)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Completed:
26
+ """A turn that produced a valid report, possibly rebuilt by the Helper."""
27
+
28
+ report: Report
29
+ rebuilt: bool = False
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Failed:
34
+ """A turn the agent reported as failed, with its normalized failure kind."""
35
+
36
+ kind: FailureKind
37
+ resets_at: datetime | None
38
+ message: str
39
+ unclassified: bool = False
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Stalled:
44
+ """No stream signal arrived within the configured timeout."""
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ProtocolError:
49
+ """The turn ended without a report even after nudges and a Helper rebuild attempt."""
50
+
51
+
52
+ TurnResult = Completed | Failed | Stalled | ProtocolError
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class TurnCounters:
57
+ """In-memory counters for the current turn only; a restart resets them."""
58
+
59
+ stall_recoveries: int = 0
60
+ unavailable_retries: int = 0
61
+ other_retries: int = 0
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class RecoveryPolicy:
66
+ """What `decide` needs beyond the result and counters: time and ladder availability."""
67
+
68
+ now: datetime
69
+ max_quota_wait: timedelta
70
+ has_next_rung: bool
71
+ has_next_rung_different_agent: bool
72
+ unavailable_initial_seconds: float
73
+ unavailable_multiplier: float
74
+ unavailable_max_seconds: float
75
+ unavailable_max_retries: int
76
+ quota_wait_exhausted: bool = False
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class WaitForQuota:
81
+ """Block the agent until `until`, then resume the step on the same session."""
82
+
83
+ until: datetime
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Backoff:
88
+ """Retry the same rung after `delay`."""
89
+
90
+ delay: timedelta
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class NextRung:
95
+ """Move to the next rung, optionally requiring a different agent."""
96
+
97
+ different_agent: bool
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ResumeTurn:
102
+ """Resume the same agent session with the continue prompt."""
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class RetryTurn:
107
+ """Re-run the current turn on the live session."""
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Escalate:
112
+ """Raise an escalation with `reason`."""
113
+
114
+ reason: EscalationReason
115
+
116
+
117
+ Action = WaitForQuota | Backoff | NextRung | ResumeTurn | RetryTurn | Escalate
118
+
119
+
120
+ def quota_block_until(resets_at: datetime | None, now: datetime) -> datetime:
121
+ """Return the agent-block deadline for a quota failure."""
122
+ return (resets_at + QUOTA_MARGIN) if resets_at is not None else (now + QUOTA_PROBE)
123
+
124
+
125
+ def with_known_quota_reset(
126
+ result: TurnResult, windows: Sequence[QuotaWindow], now: datetime
127
+ ) -> TurnResult:
128
+ """Fill a quota failure's missing reset from the exhausted `windows`, when they name one."""
129
+ if (
130
+ not isinstance(result, Failed)
131
+ or result.kind is not FailureKind.QUOTA
132
+ or result.resets_at is not None
133
+ ):
134
+ return result
135
+ resets_at = exhausted_reset(windows, now)
136
+ return result if resets_at is None else replace(result, resets_at=resets_at)
137
+
138
+
139
+ def _quota_action(resets_at: datetime | None, policy: RecoveryPolicy) -> Action:
140
+ if policy.quota_wait_exhausted:
141
+ if policy.has_next_rung_different_agent:
142
+ return NextRung(different_agent=True)
143
+ return Escalate(EscalationReason.QUOTA_EXHAUSTED)
144
+ until = quota_block_until(resets_at, policy.now)
145
+ if until - policy.now > policy.max_quota_wait:
146
+ if policy.has_next_rung_different_agent:
147
+ return NextRung(different_agent=True)
148
+ return Escalate(EscalationReason.QUOTA_EXHAUSTED)
149
+ return WaitForQuota(until=until)
150
+
151
+
152
+ def _decide_unavailable(counters: TurnCounters, policy: RecoveryPolicy) -> Action:
153
+ if counters.unavailable_retries < policy.unavailable_max_retries:
154
+ delay = policy.unavailable_initial_seconds
155
+ for _ in range(counters.unavailable_retries):
156
+ if delay >= policy.unavailable_max_seconds / policy.unavailable_multiplier:
157
+ delay = policy.unavailable_max_seconds
158
+ break
159
+ delay *= policy.unavailable_multiplier
160
+ return Backoff(delay=timedelta(seconds=min(delay, policy.unavailable_max_seconds)))
161
+ if policy.has_next_rung:
162
+ return NextRung(different_agent=False)
163
+ return Escalate(EscalationReason.LADDER_EXHAUSTED)
164
+
165
+
166
+ def _decide_failure(result: Failed, counters: TurnCounters, policy: RecoveryPolicy) -> Action:
167
+ action: Action
168
+ if result.unclassified:
169
+ action = Escalate(EscalationReason.AGENT_ERROR)
170
+ elif result.kind is FailureKind.QUOTA:
171
+ action = _quota_action(result.resets_at, policy)
172
+ elif result.kind is FailureKind.UNAVAILABLE:
173
+ action = _decide_unavailable(counters, policy)
174
+ elif result.kind is FailureKind.AUTH:
175
+ action = Escalate(EscalationReason.AGENT_AUTH)
176
+ elif result.kind is FailureKind.OTHER and counters.other_retries < OTHER_RETRIES_PER_TURN:
177
+ action = RetryTurn()
178
+ else:
179
+ action = Escalate(EscalationReason.AGENT_ERROR)
180
+ return action
181
+
182
+
183
+ def decide(result: TurnResult, counters: TurnCounters, policy: RecoveryPolicy) -> Action:
184
+ """Map a non-`Completed` turn result to the action recovery should take."""
185
+ if isinstance(result, Failed):
186
+ return _decide_failure(result, counters, policy)
187
+ if isinstance(result, Stalled):
188
+ if counters.stall_recoveries < MAX_STALL_RECOVERIES_PER_TURN:
189
+ return ResumeTurn()
190
+ return Escalate(EscalationReason.AGENT_STUCK)
191
+ if isinstance(result, ProtocolError):
192
+ return Escalate(EscalationReason.AGENT_PROTOCOL)
193
+ message = "recovery.decide is only for non-Completed turn results"
194
+ raise ValueError(message)