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,117 @@
1
+ """Off-loop, retried closing of the pull requests of cancelled tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import suppress
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.domain.faults import next_backoff
10
+ from shikiru.domain.tasks import Status
11
+ from shikiru.engine.fault_reporter import RECOVERABLE_FAULTS
12
+
13
+ if TYPE_CHECKING:
14
+ from logging import Logger
15
+
16
+ from shikiru.clock import Clock
17
+ from shikiru.config.settings import OrchestrationConfig
18
+ from shikiru.engine.fault_reporter import FaultReporter
19
+ from shikiru.engine.integration import PullRequestCloser
20
+ from shikiru.store.tasks import TaskStore
21
+
22
+ CLOSE_FAULT_SCOPE = "pull request close"
23
+
24
+
25
+ class PullRequestClosing:
26
+ """Closes cancelled tasks' open pull requests on its own task, never on the command loop.
27
+
28
+ A failed close is recorded through the `FaultReporter` and retried on a doubling backoff for
29
+ `network.pr_close_attempts` tries; after that it waits for the next engine start, whose
30
+ `restore` queues every cancelled task whose pull request is still open.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ closer: PullRequestCloser,
37
+ tasks: TaskStore,
38
+ faults: FaultReporter,
39
+ clock: Clock,
40
+ settings: OrchestrationConfig,
41
+ ) -> None:
42
+ self._closer = closer
43
+ self._tasks = tasks
44
+ self._faults = faults
45
+ self._clock = clock
46
+ self._settings = settings
47
+ self._queue: dict[str, None] = {}
48
+ self._pending = asyncio.Event()
49
+ self._task: asyncio.Task[None] | None = None
50
+
51
+ def start(self) -> None:
52
+ """Start the closing task."""
53
+ self._task = asyncio.create_task(self._run())
54
+
55
+ def request(self, task_id: str) -> None:
56
+ """Queue closing the open pull request of cancelled `task_id`, if it has one."""
57
+ self._queue[task_id] = None
58
+ self._pending.set()
59
+
60
+ def has_open(self, task_id: str) -> bool:
61
+ """Return whether `task_id` has a pull request that was neither closed nor withdrawn."""
62
+ return self._closer.has_open(task_id)
63
+
64
+ def restore(self) -> None:
65
+ """Queue every cancelled task whose pull request is still open."""
66
+ for task in self._tasks.list_all():
67
+ if task.status is Status.CANCELLED and self.has_open(task.id):
68
+ self.request(task.id)
69
+
70
+ def supervise(self, logger: Logger) -> None:
71
+ """Log a closing task failure and start a replacement task."""
72
+ task = self._task
73
+ if task is None or not task.done():
74
+ return
75
+ if not task.cancelled() and (error := task.exception()) is not None:
76
+ logger.error(
77
+ "pull request closing failed", exc_info=(type(error), error, error.__traceback__)
78
+ )
79
+ self.start()
80
+
81
+ async def shutdown(self) -> None:
82
+ """Cancel the closing task; queued closes are repeated by the next engine start."""
83
+ task, self._task = self._task, None
84
+ if task is None:
85
+ return
86
+ task.cancel()
87
+ with suppress(asyncio.CancelledError):
88
+ await task
89
+
90
+ async def _run(self) -> None:
91
+ while True:
92
+ if not self._queue:
93
+ self._pending.clear()
94
+ await self._pending.wait()
95
+ continue
96
+ task_id = next(iter(self._queue))
97
+ del self._queue[task_id]
98
+ await self._close(task_id)
99
+
100
+ async def _close(self, task_id: str) -> None:
101
+ faults = self._settings.faults
102
+ delay = 0.0
103
+ for attempt in range(1, self._settings.network.pr_close_attempts + 1):
104
+ try:
105
+ await self._closer.close_open(task_id)
106
+ except RECOVERABLE_FAULTS as error:
107
+ self._faults.report(error, scope=CLOSE_FAULT_SCOPE, task_id=task_id)
108
+ delay = next_backoff(
109
+ delay,
110
+ initial=faults.backoff_initial_seconds,
111
+ maximum=faults.backoff_max_seconds,
112
+ )
113
+ if attempt < self._settings.network.pr_close_attempts:
114
+ await self._clock.sleep(delay)
115
+ else:
116
+ self._faults.resolve(CLOSE_FAULT_SCOPE, task_id=task_id)
117
+ return
@@ -0,0 +1,177 @@
1
+ """Off-loop publication of locally integrated base branches to the remote."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import suppress
7
+ from datetime import timedelta
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.domain.pushes import is_due
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+ from datetime import datetime
15
+ from logging import Logger
16
+ from pathlib import Path
17
+
18
+ from shikiru.clock import Clock
19
+ from shikiru.domain.pushes import PushTarget
20
+ from shikiru.engine.tick_guard import TickGuard
21
+ from shikiru.store.pushes import PushTargets
22
+ from shikiru.workspace.git import Git
23
+
24
+ PUSH_FAULT_SCOPE = "base push"
25
+ REMOTE_CHECK_FAULT_SCOPE = "remote check"
26
+
27
+
28
+ class PushService:
29
+ """Registers integrated bases and pushes each when its deadline comes, on its own task.
30
+
31
+ Each base's reconciliation and push use its own `TickGuard`, while remote configuration uses
32
+ `remote_guard`. A failed base check, rejected push or timeout is recorded and retried on fault
33
+ backoff without holding back other bases. While work is pending, the service refreshes remote
34
+ refs every `remote_recheck_seconds`; matching local and remote SHAs clear only the registrations
35
+ observed by that check. A remote that is not configured is not a fault: work stays pending and
36
+ the remote is checked again after `remote_recheck_seconds`. A push acknowledges only the
37
+ integrations that were registered when it started.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ targets: PushTargets,
44
+ git: Git,
45
+ guard_for: Callable[[str], TickGuard],
46
+ remote_guard: TickGuard,
47
+ clock: Clock,
48
+ project_root: Path,
49
+ remote: str,
50
+ interval_seconds: int,
51
+ remote_recheck_seconds: int,
52
+ fetch_timeout_seconds: float,
53
+ ) -> None:
54
+ """Push from `project_root` to `remote`, an interval of `interval_seconds` apart."""
55
+ self._targets = targets
56
+ self._git = git
57
+ self._guard_for = guard_for
58
+ self._guards: dict[str, TickGuard] = {}
59
+ self._remote_guard = remote_guard
60
+ self._clock = clock
61
+ self._project_root = project_root
62
+ self._remote = remote
63
+ self._interval = interval_seconds
64
+ self._remote_recheck = timedelta(seconds=remote_recheck_seconds)
65
+ self._fetch_timeout = fetch_timeout_seconds
66
+ self._task: asyncio.Task[None] | None = None
67
+ self._next_remote_check: datetime | None = None
68
+ self._remote_available: bool | None = None
69
+
70
+ def register(self, base: str) -> None:
71
+ """Record an integration of `base` that must reach the remote."""
72
+ current = self._targets.get(base)
73
+ self._targets.register(base, now=self._clock.now(), interval=self._interval)
74
+ if current is None or not current.pending:
75
+ self._remote_available = None
76
+ self._next_remote_check = None
77
+
78
+ def poll(self, logger: Logger) -> None:
79
+ """Log a failed push task and start one when work is due and none is running."""
80
+ task = self._task
81
+ if task is not None:
82
+ if not task.done():
83
+ return
84
+ self._task = None
85
+ if not task.cancelled() and (error := task.exception()) is not None:
86
+ logger.error("base push failed", exc_info=(type(error), error, error.__traceback__))
87
+ if self._due():
88
+ self._task = asyncio.create_task(self._push_due())
89
+
90
+ async def finish_outstanding(self) -> None:
91
+ """Wait for the push in flight, if there is one."""
92
+ task = self._task
93
+ if task is not None:
94
+ await asyncio.wait((task,))
95
+
96
+ async def shutdown(self) -> None:
97
+ """Cancel an outstanding push; its pending work is sent by the next engine start."""
98
+ task, self._task = self._task, None
99
+ if task is None:
100
+ return
101
+ task.cancel()
102
+ with suppress(asyncio.CancelledError):
103
+ await task
104
+
105
+ def _due(self) -> bool:
106
+ now = self._clock.now()
107
+ pending = self._targets.pending()
108
+ if not pending:
109
+ return False
110
+ remote_check_due = self._next_remote_check is None or now >= self._next_remote_check
111
+ if remote_check_due:
112
+ return True
113
+ if self._remote_available is False:
114
+ return False
115
+ return any(is_due(target, now=now, interval=self._interval) for target in pending)
116
+
117
+ async def _push_due(self) -> None:
118
+ pending = self._targets.pending()
119
+ if not pending:
120
+ return
121
+ now = self._clock.now()
122
+ remote_check_due = self._next_remote_check is None or now >= self._next_remote_check
123
+ push_due = any(is_due(target, now=now, interval=self._interval) for target in pending)
124
+ if not remote_check_due and not push_due:
125
+ return
126
+ configured = await self._remote_guard.attempt(self._remote_configured)
127
+ if configured is None:
128
+ if remote_check_due:
129
+ self._next_remote_check = self._clock.now() + self._remote_recheck
130
+ return
131
+ self._remote_available = configured
132
+ if not configured:
133
+ self._next_remote_check = self._clock.now() + self._remote_recheck
134
+ return
135
+ if remote_check_due:
136
+ self._next_remote_check = self._clock.now() + self._remote_recheck
137
+ for target in pending:
138
+ await self._guard(target.base).attempt(
139
+ lambda target=target: self._reconcile(target)
140
+ )
141
+ for target in self._targets.pending():
142
+ if is_due(target, now=self._clock.now(), interval=self._interval):
143
+ await self._guard(target.base).attempt(lambda target=target: self._push(target))
144
+
145
+ async def _reconcile(self, target: PushTarget) -> None:
146
+ remote_sha = await self._git.fetch_remote_branch(
147
+ self._project_root,
148
+ self._remote,
149
+ target.base,
150
+ timeout=self._fetch_timeout,
151
+ )
152
+ local_sha = await self._git.local_branch_sha(self._project_root, target.base)
153
+ if remote_sha is not None and remote_sha == local_sha:
154
+ self._targets.reconcile(
155
+ target.base,
156
+ registered=target.registered,
157
+ now=self._clock.now(),
158
+ interval=self._interval,
159
+ )
160
+
161
+ async def _remote_configured(self) -> bool:
162
+ return await self._git.remote_configured(self._project_root, self._remote)
163
+
164
+ def _guard(self, base: str) -> TickGuard:
165
+ if base not in self._guards:
166
+ self._guards[base] = self._guard_for(base)
167
+ return self._guards[base]
168
+
169
+ async def _push(self, target: PushTarget) -> bool:
170
+ await self._git.push_base(self._project_root, self._remote, target.base)
171
+ self._targets.acknowledge(
172
+ target.base,
173
+ registered=target.registered,
174
+ now=self._clock.now(),
175
+ interval=self._interval,
176
+ )
177
+ return True
@@ -0,0 +1,79 @@
1
+ """Owned threads for blocking quota reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.errors import ShikiruError
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+ from datetime import datetime
14
+
15
+ from shikiru.agents.contracts import QuotaReading
16
+
17
+ THREAD_NAME_PREFIX = "quota-probe-"
18
+
19
+
20
+ def _deliver(
21
+ future: asyncio.Future[QuotaReading | None],
22
+ reading: QuotaReading | None,
23
+ error: Exception | None,
24
+ ) -> None:
25
+ if future.done():
26
+ return
27
+ if error is not None:
28
+ future.set_exception(error)
29
+ else:
30
+ future.set_result(reading)
31
+
32
+
33
+ class QuotaProbeThreads:
34
+ """Runs blocking quota reads, each agent's on at most one daemon thread at a time.
35
+
36
+ A blocked read cannot be interrupted, so it is never abandoned and replaced: while an
37
+ agent's thread has not returned, a new read of that agent is refused. The threads are
38
+ daemons, so a read that never returns delays neither shutdown nor process exit.
39
+ """
40
+
41
+ def __init__(self) -> None:
42
+ """Start with no thread running."""
43
+ self._threads: dict[str, threading.Thread] = {}
44
+
45
+ def busy(self, agent: str) -> bool:
46
+ """Return whether `agent`'s previous read is still running."""
47
+ thread = self._threads.get(agent)
48
+ return thread is not None and thread.is_alive()
49
+
50
+ def start(
51
+ self,
52
+ agent: str,
53
+ read: Callable[[datetime], QuotaReading | None],
54
+ since: datetime,
55
+ ) -> asyncio.Future[QuotaReading | None]:
56
+ """Run `read(since)` on a new thread and return a future for its outcome.
57
+
58
+ Raises `ShikiruError` while `agent`'s previous read is still running.
59
+ """
60
+ if self.busy(agent):
61
+ message = f"the previous quota probe of {agent} is still waiting for its source"
62
+ raise ShikiruError(message)
63
+ loop = asyncio.get_running_loop()
64
+ future: asyncio.Future[QuotaReading | None] = loop.create_future()
65
+
66
+ def run() -> None:
67
+ reading: QuotaReading | None = None
68
+ failure: Exception | None = None
69
+ try:
70
+ reading = read(since)
71
+ except Exception as error: # noqa: BLE001
72
+ failure = error
73
+ if not loop.is_closed():
74
+ loop.call_soon_threadsafe(_deliver, future, reading, failure)
75
+
76
+ thread = threading.Thread(target=run, name=f"{THREAD_NAME_PREFIX}{agent}", daemon=True)
77
+ self._threads[agent] = thread
78
+ thread.start()
79
+ return future
@@ -0,0 +1,210 @@
1
+ """Revalidates persisted quota blocks against fresh evidence from the agent plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from datetime import timedelta
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.agents.contracts import quota_probe_for
11
+ from shikiru.domain.events import EventKind
12
+ from shikiru.domain.quota_blocks import block_released
13
+ from shikiru.domain.quota_waits import QuotaAction, ScheduledQuotaAction
14
+ from shikiru.engine.quota_probe_threads import QuotaProbeThreads
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Sequence
18
+ from datetime import datetime
19
+
20
+ from shikiru.agents.contracts import QuotaProbe, QuotaReading, QuotaWindow
21
+ from shikiru.clock import Clock
22
+ from shikiru.config.settings import QuotaRefreshConfig
23
+ from shikiru.engine.agents import AgentPool
24
+ from shikiru.engine.fault_reporter import FaultReporter
25
+ from shikiru.engine.quota_schedule import QuotaSchedule
26
+ from shikiru.store.events import EventLog
27
+ from shikiru.store.quotas import AgentBlock, QuotaStore
28
+
29
+ QUOTA_REFRESH_SCOPE = "quota refresh"
30
+ QUOTA_RELEASE_REASON = "newer quota reading shows available quota"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class _Probe:
35
+ blocked_at: datetime
36
+ task: asyncio.Task[None]
37
+ demanded: bool
38
+
39
+
40
+ class QuotaReconciler:
41
+ """Releases a quota block when a newer applicable reading shows its quota is available.
42
+
43
+ A reading counts only when it was observed after the block and covers every window the
44
+ block was exhausted on. Out-of-turn probes run beside the caller on a bounded interval, one
45
+ per block at a time and each for a bounded time; an explicit requeue forces one, and a probe
46
+ that fails or times out is recorded as an engine fault. The result of a probe whose block
47
+ has since changed is dropped.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ quotas: QuotaStore,
54
+ agents: AgentPool,
55
+ events: EventLog,
56
+ clock: Clock,
57
+ settings: QuotaRefreshConfig,
58
+ faults: FaultReporter,
59
+ schedule: QuotaSchedule,
60
+ ) -> None:
61
+ """Probe through `agents`, persist in `quotas`, record outcomes in `events`.
62
+
63
+ The next examination or retry of each block is recorded in `schedule`.
64
+ """
65
+ self._quotas = quotas
66
+ self._agents = agents
67
+ self._events = events
68
+ self._clock = clock
69
+ self._settings = settings
70
+ self._faults = faults
71
+ self._probed_at: dict[str, datetime] = {}
72
+ self._probes: dict[str, _Probe] = {}
73
+ self._threads = QuotaProbeThreads()
74
+ self._schedule = schedule
75
+
76
+ def observe(self, agent: str, windows: Sequence[QuotaWindow]) -> None:
77
+ """Record `windows` for `agent`, first releasing its block when they prove it obsolete."""
78
+ now = self._clock.now()
79
+ block = self._quotas.block_for(agent)
80
+ if (
81
+ block is not None
82
+ and block.until > now
83
+ and block_released(
84
+ blocked_at=block.blocked_at,
85
+ stored=self._quotas.windows_for(agent),
86
+ reading=windows,
87
+ now=now,
88
+ )
89
+ ):
90
+ self._quotas.clear_block(agent)
91
+ self._events.append(
92
+ EventKind.QUOTA_CLEARED,
93
+ message=f"{agent} quota block cleared",
94
+ data={"agent": agent, "reason": QUOTA_RELEASE_REASON},
95
+ )
96
+ for window in windows:
97
+ self._quotas.record(agent, window)
98
+
99
+ def request_refresh(
100
+ self, agent: str, *, demanded_at: datetime | None = None
101
+ ) -> asyncio.Task[None] | None:
102
+ """Start a supervised probe of `agent`'s plugin while its block is in effect.
103
+
104
+ Never waits for the probe. Probes at most once per interval, unless `demanded_at`, such
105
+ as a task requeue, is later than the last probe; a demanded probe is not delayed by the
106
+ interval, and a requeue that arrives during a running probe of the same block makes that
107
+ probe demanded. Never twice at once for the same block; a probe of a block since replaced
108
+ is cancelled. Returns the running probe, or `None` when nothing was started or the block is
109
+ not examined.
110
+ """
111
+ now = self._clock.now()
112
+ block = self._quotas.block_for(agent)
113
+ if block is None or block.until <= now:
114
+ return None
115
+ last = self._probed_at.get(agent)
116
+ demanded = demanded_at is not None and (last is None or demanded_at > last)
117
+ running = self._probes.get(agent)
118
+ if running is not None and not running.task.done():
119
+ if running.blocked_at == block.blocked_at:
120
+ if demanded and not running.demanded:
121
+ self._probes[agent] = _Probe(running.blocked_at, running.task, demanded=True)
122
+ return running.task
123
+ running.task.cancel()
124
+ interval = timedelta(seconds=self._settings.interval_seconds)
125
+ if last is not None and now - last < interval and not demanded:
126
+ return None
127
+ self._probed_at[agent] = now
128
+ task = asyncio.Task(self._probe(agent, block, now + interval), eager_start=True)
129
+ self._probes[agent] = _Probe(block.blocked_at, task, demanded=demanded)
130
+ return task
131
+
132
+ async def refresh(self, agent: str, *, demanded_at: datetime | None = None) -> None:
133
+ """Probe `agent`'s plugin as `request_refresh` does and wait until the probe ends."""
134
+ task = self.request_refresh(agent, demanded_at=demanded_at)
135
+ if task is not None:
136
+ await asyncio.wait({task})
137
+
138
+ @property
139
+ def probing(self) -> bool:
140
+ """Return whether any probe is still running."""
141
+ return any(not probe.task.done() for probe in self._probes.values())
142
+
143
+ def awaiting_demanded_probe(self, block: AgentBlock) -> bool:
144
+ """Return whether a requeue-demanded probe of `block` is still running."""
145
+ probe = self._probes.get(block.agent)
146
+ return (
147
+ probe is not None
148
+ and probe.demanded
149
+ and probe.blocked_at == block.blocked_at
150
+ and not probe.task.done()
151
+ )
152
+
153
+ async def shutdown(self) -> None:
154
+ """Cancel every probe in flight and wait until each has ended."""
155
+ tasks = [probe.task for probe in self._probes.values()]
156
+ self._probes.clear()
157
+ for task in tasks:
158
+ task.cancel()
159
+ await asyncio.gather(*tasks, return_exceptions=True)
160
+
161
+ async def _probe(self, agent: str, block: AgentBlock, next_check: datetime) -> None:
162
+ async with self._faults.isolate(f"{QUOTA_REFRESH_SCOPE} {agent}"):
163
+ self.expect_check(agent, next_check)
164
+ probe = quota_probe_for(self._agents.get(agent))
165
+ if probe is None:
166
+ return
167
+ reading = await self._read(agent, probe, block.blocked_at)
168
+ current = self._quotas.block_for(agent)
169
+ if (
170
+ reading is not None
171
+ and current is not None
172
+ and current.blocked_at == block.blocked_at
173
+ ):
174
+ self.observe(agent, reading.windows)
175
+
176
+ async def _read(
177
+ self, agent: str, probe: QuotaProbe, blocked_at: datetime
178
+ ) -> QuotaReading | None:
179
+ timeout = self._settings.timeout_seconds
180
+ read = self._threads.start(agent, probe.read_quota, blocked_at)
181
+ timer = asyncio.create_task(self._clock.sleep(timeout))
182
+ try:
183
+ await asyncio.wait({read, timer}, return_when=asyncio.FIRST_COMPLETED)
184
+ if not read.done():
185
+ message = f"quota probe did not answer within {timeout} seconds"
186
+ raise TimeoutError(message)
187
+ return read.result()
188
+ finally:
189
+ read.cancel()
190
+ timer.cancel()
191
+ await asyncio.gather(read, timer, return_exceptions=True)
192
+
193
+ def expect_check(self, agent: str, at: datetime) -> None:
194
+ """Record that `agent`'s block is next examined at `at`, or retried when it lifts first.
195
+
196
+ An agent whose plugin cannot probe is not examined: its block is retried when it lifts.
197
+ """
198
+ block = self._quotas.block_for(agent)
199
+ if block is None:
200
+ return
201
+ if block.until <= at or quota_probe_for(self._agents.get(agent)) is None:
202
+ scheduled = ScheduledQuotaAction(QuotaAction.RETRY, block.until, block.blocked_at)
203
+ else:
204
+ scheduled = ScheduledQuotaAction(QuotaAction.NEXT_CHECK, at, block.blocked_at)
205
+ self._schedule.record(agent, scheduled)
206
+
207
+ @property
208
+ def interval_seconds(self) -> float:
209
+ """Return the longest an obsolete block can go unnoticed while someone waits on it."""
210
+ return self._settings.interval_seconds
@@ -0,0 +1,24 @@
1
+ """What the engine will next do about each quota-blocked agent, for the views that show it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from shikiru.domain.quota_waits import ScheduledQuotaAction
9
+
10
+
11
+ class QuotaSchedule:
12
+ """The next scheduled action per agent, in memory: it is rebuilt as the engine acts again."""
13
+
14
+ def __init__(self) -> None:
15
+ """Start with no action scheduled."""
16
+ self._actions: dict[str, ScheduledQuotaAction] = {}
17
+
18
+ def record(self, agent: str, action: ScheduledQuotaAction) -> None:
19
+ """Remember `action` as `agent`'s next one."""
20
+ self._actions[agent] = action
21
+
22
+ def get(self, agent: str) -> ScheduledQuotaAction | None:
23
+ """Return `agent`'s recorded action, or none when none was recorded."""
24
+ return self._actions.get(agent)