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,102 @@
1
+ """Which windows and role panes of a task are in a quota wait, from authoritative current facts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.domain.ladder import pick_rung
9
+ from shikiru.domain.quota_waits import current_quota_wait_events
10
+ from shikiru.domain.scheduling import WaitReason
11
+ from shikiru.domain.steps import task_acting_role
12
+ from shikiru.domain.tasks import Role, Status
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+ from datetime import datetime
17
+
18
+ from shikiru.clock import Clock
19
+ from shikiru.config.settings import Settings
20
+ from shikiru.domain.quota_waits import QuotaAction
21
+ from shikiru.domain.tasks import Task
22
+ from shikiru.engine.quota_schedule import QuotaSchedule
23
+ from shikiru.store.events import EventLog
24
+ from shikiru.store.quotas import AgentBlock, QuotaStore
25
+
26
+ _QUOTA_ROLES = (Role.LEADER, Role.IMPLEMENTER)
27
+ _TURN_STATUSES = (Status.RUNNING, Status.INTEGRATING)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RoleQuotaWait:
32
+ """A role whose agent is quota blocked, with the engine's next action on it when known."""
33
+
34
+ action: QuotaAction | None = None
35
+ at: datetime | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TaskQuotaWait:
40
+ """A task in a quota wait and the roles whose agents the wait is about.
41
+
42
+ A queued task waits when any required role is blocked, whichever role acts next; a running
43
+ task waits in the turn of its acting role only. `roles` can be empty while the persisted
44
+ wait reason still names quota, as the task list also shows.
45
+ """
46
+
47
+ roles: Mapping[Role, RoleQuotaWait]
48
+
49
+
50
+ class QuotaWaits:
51
+ """Resolves a task's quota wait with the same rules as the task list's quota marker."""
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ quotas: QuotaStore,
57
+ events: EventLog,
58
+ schedule: QuotaSchedule,
59
+ clock: Clock,
60
+ settings: Settings,
61
+ ) -> None:
62
+ """Read blocks from `quotas`, turn facts from `events`, next actions from `schedule`."""
63
+ self._quotas = quotas
64
+ self._events = events
65
+ self._schedule = schedule
66
+ self._clock = clock
67
+ self._settings = settings
68
+
69
+ def resolve(self, task: Task) -> TaskQuotaWait | None:
70
+ """Return `task`'s quota wait, or none when it is not waiting on quota."""
71
+ if task.status is Status.QUEUED:
72
+ return self._queued(task)
73
+ if task.status in _TURN_STATUSES and task.step is not None:
74
+ return self._running(task)
75
+ return None
76
+
77
+ def _queued(self, task: Task) -> TaskQuotaWait | None:
78
+ if task.wait_reason != WaitReason.QUOTA.value:
79
+ return None
80
+ blocks = {block.agent: block for block in self._quotas.active_blocks(self._clock.now())}
81
+ roles = {
82
+ role: self._role_wait(blocks[agent])
83
+ for role in _QUOTA_ROLES
84
+ if (agent := pick_rung(task, role, self._settings).agent) in blocks
85
+ }
86
+ return TaskQuotaWait(roles)
87
+
88
+ def _running(self, task: Task) -> TaskQuotaWait | None:
89
+ role = task_acting_role(task)
90
+ agent = pick_rung(task, role, self._settings).agent
91
+ if current_quota_wait_events(task, role, agent, self._events.latest) is None:
92
+ return None
93
+ block = self._quotas.block_for(agent)
94
+ if block is None or block.until <= self._clock.now():
95
+ return None
96
+ return TaskQuotaWait({role: self._role_wait(block)})
97
+
98
+ def _role_wait(self, block: AgentBlock) -> RoleQuotaWait:
99
+ scheduled = self._schedule.get(block.agent)
100
+ if scheduled is None or scheduled.blocked_at != block.blocked_at:
101
+ return RoleQuotaWait()
102
+ return RoleQuotaWait(scheduled.action, scheduled.at)
@@ -0,0 +1,91 @@
1
+ """Engine restart recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.tasks import Status
8
+ from shikiru.paths import task_root
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Awaitable, Callable
12
+ from pathlib import Path
13
+
14
+ from shikiru.domain.tasks import Task
15
+ from shikiru.engine.fault_reporter import FaultReporter
16
+ from shikiru.engine.layout_guard import LayoutGuard
17
+ from shikiru.engine.turn_processes import TurnProcesses
18
+ from shikiru.store.tasks import TaskStore
19
+
20
+ _IN_FLIGHT = (Status.RUNNING, Status.INTEGRATING)
21
+ _TERMINAL = (Status.DONE, Status.CANCELLED)
22
+
23
+
24
+ class Reconciler:
25
+ """Restore windows, end orphaned turn processes and re-run in-flight steps at engine start."""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ tasks: TaskStore,
31
+ layout: LayoutGuard,
32
+ processes: TurnProcesses,
33
+ resume_task: Callable[[str], Awaitable[None]],
34
+ finish_task: Callable[[Task], Awaitable[Path | None]],
35
+ restore_escalation: Callable[[Task], Awaitable[None]],
36
+ restore_pull_request_closes: Callable[[], None],
37
+ faults: FaultReporter,
38
+ project_root: Path,
39
+ ) -> None:
40
+ """Resume in-flight tasks, finish terminal ones, restore escalations of escalated ones.
41
+
42
+ A fault in one step or task is recorded through `faults`; the rest goes on.
43
+ """
44
+ self._tasks = tasks
45
+ self._layout = layout
46
+ self._processes = processes
47
+ self._resume_task = resume_task
48
+ self._finish_task = finish_task
49
+ self._restore_escalation = restore_escalation
50
+ self._restore_pull_request_closes = restore_pull_request_closes
51
+ self._faults = faults
52
+ self._project_root = project_root
53
+ self._orphans_ended = False
54
+
55
+ async def reconcile(self) -> None:
56
+ """Bring a restarted engine back in line with the persisted state.
57
+
58
+ Pending commands need nothing here: the engine loop processes them once this has
59
+ completed. A fault in one step or one task is recorded and never stops the others.
60
+ Ending the orphaned turn processes is the exception: a fault there aborts the reconcile,
61
+ which is retried before anything starts, so no flow runs beside a live orphan and none
62
+ ends a turn the engine started itself.
63
+ """
64
+ active = self._tasks.list_active()
65
+ terminal = [
66
+ task
67
+ for task in self._tasks.list_all()
68
+ if task.status in _TERMINAL and task_root(self._project_root, task.id).exists()
69
+ ]
70
+ async with self._faults.isolate("reconcile layout"):
71
+ await self._layout.restore(active)
72
+ if not self._orphans_ended:
73
+ await self._processes.end_orphans()
74
+ self._orphans_ended = True
75
+ for task in terminal:
76
+ async with self._faults.isolate("reconcile cleanup", task_id=task.id):
77
+ await self._finish_task(task)
78
+ async with self._faults.isolate("reconcile pull request closes"):
79
+ self._restore_pull_request_closes()
80
+ await self.restore_escalations()
81
+ for task in active:
82
+ if task.status in _IN_FLIGHT and not task.held:
83
+ async with self._faults.isolate("reconcile resume", task_id=task.id):
84
+ await self._resume_task(task.id)
85
+
86
+ async def restore_escalations(self) -> None:
87
+ """Give every escalated task an open escalation with its packet on disk."""
88
+ for task in self._tasks.list_active():
89
+ if task.status is Status.ESCALATED:
90
+ async with self._faults.isolate("escalation restore", task_id=task.id):
91
+ await self._restore_escalation(task)
@@ -0,0 +1,284 @@
1
+ """Recovery of a turn that ended without a report: retries, quota waits and rung moves."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from dataclasses import dataclass
7
+ from datetime import timedelta
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.agents.contracts import FailureKind
11
+ from shikiru.domain.events import EventKind
12
+ from shikiru.domain.ladder import move_up, pick_rung
13
+ from shikiru.domain.recovery import (
14
+ QUOTA_MARGIN,
15
+ Backoff,
16
+ Escalate,
17
+ Failed,
18
+ NextRung,
19
+ RecoveryPolicy,
20
+ ResumeTurn,
21
+ RetryTurn,
22
+ TurnCounters,
23
+ WaitForQuota,
24
+ decide,
25
+ quota_block_until,
26
+ with_known_quota_reset,
27
+ )
28
+ from shikiru.domain.steps import Transition
29
+ from shikiru.domain.tasks import EscalationReason
30
+ from shikiru.engine.events import emit_turn_failed
31
+ from shikiru.engine.turn_context import ORDINARY_TURN, NextTurn
32
+
33
+ if TYPE_CHECKING:
34
+ from datetime import datetime
35
+
36
+ from shikiru.clock import Clock
37
+ from shikiru.config.settings import Settings
38
+ from shikiru.domain.recovery import Action, TurnResult
39
+ from shikiru.domain.tasks import Role, Step, Task
40
+ from shikiru.engine.quota_reconciler import QuotaReconciler
41
+ from shikiru.engine.transitions import TransitionWriter
42
+ from shikiru.engine.turn_execution import TurnExecution
43
+ from shikiru.store.events import EventLog
44
+ from shikiru.store.quotas import QuotaStore
45
+ from shikiru.store.tasks import TaskStore
46
+
47
+ QUOTA_BLOCK_REASON = "quota"
48
+ _QUOTA_WAIT_EXHAUSTED_MESSAGE = "quota wait exceeded"
49
+ _CONTINUE = NextTurn(continue_turn=True)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RecoveryStep:
54
+ """What the flow does after a failed turn: run again, re-decide on `execution` or escalate."""
55
+
56
+ task: Task
57
+ counters: TurnCounters
58
+ execution: TurnExecution | None = None
59
+ escalation: EscalationReason | None = None
60
+ next_turn: NextTurn = ORDINARY_TURN
61
+
62
+
63
+ class TurnRecovery:
64
+ """Decides and carries out the recovery of a failed turn; escalation stays with the flow."""
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ tasks: TaskStore,
70
+ events: EventLog,
71
+ quota: QuotaStore,
72
+ reconciler: QuotaReconciler,
73
+ clock: Clock,
74
+ writer: TransitionWriter,
75
+ settings: Settings,
76
+ ) -> None:
77
+ """Recover through the ladder, the quota blocks and the clock."""
78
+ self._tasks = tasks
79
+ self._events = events
80
+ self._quota = quota
81
+ self._reconciler = reconciler
82
+ self._clock = clock
83
+ self._writer = writer
84
+ self._settings = settings
85
+
86
+ async def recover(
87
+ self,
88
+ task: Task,
89
+ step: Step,
90
+ role: Role,
91
+ execution: TurnExecution,
92
+ counters: TurnCounters,
93
+ ) -> RecoveryStep:
94
+ """Apply the recovery `domain.recovery.decide` chooses for `execution`'s failed result."""
95
+ execution = self._with_known_reset(execution)
96
+ result = execution.result
97
+ emit_turn_failed(
98
+ self._events,
99
+ task,
100
+ step,
101
+ result,
102
+ rung=execution.rung,
103
+ run_id=execution.run_id,
104
+ turn=execution.turn_dir.name,
105
+ role=execution.role,
106
+ )
107
+ action = decide(
108
+ result,
109
+ counters,
110
+ self._policy(task, role, quota_wait_exhausted=execution.quota_wait_exhausted),
111
+ )
112
+ if isinstance(result, Failed) and result.kind is FailureKind.QUOTA:
113
+ self._block_quota(task, role, result.resets_at)
114
+ return await self._apply(
115
+ task, step, role, execution=execution, counters=counters, action=action
116
+ )
117
+
118
+ def _with_known_reset(self, execution: TurnExecution) -> TurnExecution:
119
+ windows = self._quota.windows_for(execution.rung.agent)
120
+ result = with_known_quota_reset(execution.result, windows, self._clock.now())
121
+ return dataclasses.replace(execution, result=result)
122
+
123
+ def _policy(self, task: Task, role: Role, *, quota_wait_exhausted: bool) -> RecoveryPolicy:
124
+ current = move_up(task, role, self._settings)
125
+ different = move_up(
126
+ task, role, self._settings, different_agent=True, blocked_agents=self._blocked_agents()
127
+ )
128
+ retries = self._settings.orchestration.retries
129
+ return RecoveryPolicy(
130
+ now=self._clock.now(),
131
+ max_quota_wait=timedelta(seconds=self._settings.orchestration.timeouts.max_quota_wait),
132
+ has_next_rung=current is not None,
133
+ has_next_rung_different_agent=different is not None,
134
+ unavailable_initial_seconds=retries.unavailable_initial_seconds,
135
+ unavailable_multiplier=retries.unavailable_multiplier,
136
+ unavailable_max_seconds=retries.unavailable_max_seconds,
137
+ unavailable_max_retries=retries.unavailable_max_retries,
138
+ quota_wait_exhausted=quota_wait_exhausted,
139
+ )
140
+
141
+ async def _apply(
142
+ self,
143
+ task: Task,
144
+ step: Step,
145
+ role: Role,
146
+ *,
147
+ execution: TurnExecution,
148
+ counters: TurnCounters,
149
+ action: Action,
150
+ ) -> RecoveryStep:
151
+ if isinstance(action, NextRung):
152
+ return await self._next_rung(task, step, role, execution.result, action)
153
+ if isinstance(action, Escalate):
154
+ return RecoveryStep(task, TurnCounters(), escalation=action.reason)
155
+ return await self._retry(task, role, execution, counters, action)
156
+
157
+ async def _retry(
158
+ self,
159
+ task: Task,
160
+ role: Role,
161
+ execution: TurnExecution,
162
+ counters: TurnCounters,
163
+ action: WaitForQuota | Backoff | ResumeTurn | RetryTurn,
164
+ ) -> RecoveryStep:
165
+ if isinstance(action, RetryTurn):
166
+ return RecoveryStep(
167
+ task, dataclasses.replace(counters, other_retries=counters.other_retries + 1)
168
+ )
169
+ if isinstance(action, Backoff):
170
+ await self._clock.sleep(action.delay.total_seconds())
171
+ retried = dataclasses.replace(
172
+ counters, unavailable_retries=counters.unavailable_retries + 1
173
+ )
174
+ return RecoveryStep(self._tasks.require(task.id), retried)
175
+ if isinstance(action, ResumeTurn):
176
+ resumed = dataclasses.replace(counters, stall_recoveries=counters.stall_recoveries + 1)
177
+ return RecoveryStep(self._tasks.require(task.id), resumed, next_turn=_CONTINUE)
178
+ terminal = await self._wait_for_quota(task, role)
179
+ if terminal is None:
180
+ return RecoveryStep(self._tasks.require(task.id), counters, next_turn=_CONTINUE)
181
+ exhausted = dataclasses.replace(execution, result=terminal, quota_wait_exhausted=True)
182
+ return RecoveryStep(self._tasks.require(task.id), counters, execution=exhausted)
183
+
184
+ async def _next_rung(
185
+ self, task: Task, step: Step, role: Role, result: TurnResult, action: NextRung
186
+ ) -> RecoveryStep:
187
+ updated = await self._move_rung(task, step, role, action.different_agent, result)
188
+ if updated is not None:
189
+ return RecoveryStep(
190
+ updated, TurnCounters(), next_turn=NextTurn(handoff=action.different_agent)
191
+ )
192
+ reason = (
193
+ EscalationReason.QUOTA_EXHAUSTED
194
+ if action.different_agent
195
+ else EscalationReason.LADDER_EXHAUSTED
196
+ )
197
+ return RecoveryStep(task, TurnCounters(), escalation=reason)
198
+
199
+ async def _move_rung(
200
+ self,
201
+ task: Task,
202
+ step: Step,
203
+ role: Role,
204
+ different_agent: bool,
205
+ result: TurnResult,
206
+ ) -> Task | None:
207
+ move = move_up(
208
+ task,
209
+ role,
210
+ self._settings,
211
+ different_agent=different_agent,
212
+ blocked_agents=self._blocked_agents() if different_agent else frozenset(),
213
+ )
214
+ if move is None:
215
+ return None
216
+ old_rung = pick_rung(task, role, self._settings)
217
+ transition = dataclasses.replace(
218
+ Transition.stay(task, step=step, status=task.status),
219
+ counters=move.counters,
220
+ rung_index=move.rung_index,
221
+ cleared_overrides=move.cleared_overrides,
222
+ )
223
+ updated = await self._writer.apply(task, transition, None)
224
+ new_rung = pick_rung(updated, role, self._settings)
225
+ self._events.append(
226
+ EventKind.RUNG_CHANGED,
227
+ task_id=task.id,
228
+ message=f"{task.id} {role.value} moved from {old_rung.model} to {new_rung.model}",
229
+ data={
230
+ "role": role.value,
231
+ "from": old_rung.model,
232
+ "to": new_rung.model,
233
+ "different_agent": different_agent,
234
+ "result": type(result).__name__,
235
+ },
236
+ )
237
+ return updated
238
+
239
+ def _blocked_agents(self) -> frozenset[str]:
240
+ return frozenset(block.agent for block in self._quota.active_blocks(self._clock.now()))
241
+
242
+ def _block_quota(self, task: Task, role: Role, resets_at: datetime | None) -> None:
243
+ agent = pick_rung(task, role, self._settings).agent
244
+ now = self._clock.now()
245
+ until = quota_block_until(resets_at, now)
246
+ self._quota.block(agent, until, QUOTA_BLOCK_REASON, now)
247
+ self._events.append(
248
+ EventKind.QUOTA_BLOCKED,
249
+ task_id=task.id,
250
+ message=f"{agent} quota blocked until {until.isoformat()}",
251
+ data={"agent": agent, "until": until.isoformat(), "reason": QUOTA_BLOCK_REASON},
252
+ )
253
+
254
+ async def _wait_for_quota(self, task: Task, role: Role) -> Failed | None:
255
+ agent = pick_rung(task, role, self._settings).agent
256
+ deadline = self._clock.now() + timedelta(
257
+ seconds=self._settings.orchestration.timeouts.max_quota_wait
258
+ )
259
+ while True:
260
+ current = self._quota.block_for(agent)
261
+ now = self._clock.now()
262
+ if current is None:
263
+ return None
264
+ if current.until > now:
265
+ if now >= deadline:
266
+ return Failed(
267
+ kind=FailureKind.QUOTA,
268
+ resets_at=current.until - QUOTA_MARGIN,
269
+ message=_QUOTA_WAIT_EXHAUSTED_MESSAGE,
270
+ )
271
+ remaining = min(current.until, deadline) - now
272
+ delay = min(remaining.total_seconds(), self._reconciler.interval_seconds)
273
+ self._reconciler.expect_check(agent, now + timedelta(seconds=delay))
274
+ await self._clock.sleep(delay)
275
+ await self._reconciler.refresh(agent)
276
+ continue
277
+ self._quota.clear_block(agent)
278
+ self._events.append(
279
+ EventKind.QUOTA_CLEARED,
280
+ task_id=task.id,
281
+ message=f"{agent} quota block cleared",
282
+ data={"agent": agent},
283
+ )
284
+ return None
@@ -0,0 +1,102 @@
1
+ """Prunes finished history so a long-running engine stays bounded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import shutil
8
+ from datetime import timedelta
9
+ from typing import TYPE_CHECKING
10
+
11
+ from shikiru.domain.tasks import Status
12
+ from shikiru.paths import run_dir, task_root
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+ from shikiru.clock import Clock
20
+ from shikiru.config.settings import RetentionConfig
21
+ from shikiru.store.notification_cursor import NotificationCursor
22
+ from shikiru.store.retention import StateRetention
23
+ from shikiru.store.tasks import TaskStore
24
+
25
+
26
+ class Retention:
27
+ """Removes long-finished tasks' run directories and events and old commands, periodically.
28
+
29
+ One pass runs every `interval_seconds`, also removing old engine events. A task's files and
30
+ events go only when the task is `done` or `cancelled` past the horizon and nothing unresolved
31
+ still needs them: no open escalation, no worktree kept for its unintegrated work, no hold, no
32
+ pull request of a cancelled task left open. Run directories go before their events, so an
33
+ interrupted pass is simply repeated.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ store: StateRetention,
40
+ tasks: TaskStore,
41
+ cursor: NotificationCursor,
42
+ project_root: Path,
43
+ has_open_pull_request: Callable[[str], bool],
44
+ clock: Clock,
45
+ settings: RetentionConfig,
46
+ ) -> None:
47
+ """Prune `store` and the run directories under `project_root` on `clock`'s schedule."""
48
+ self._store = store
49
+ self._tasks = tasks
50
+ self._cursor = cursor
51
+ self._project_root = project_root
52
+ self._has_open_pull_request = has_open_pull_request
53
+ self._clock = clock
54
+ self._settings = settings
55
+ self._next_run: datetime | None = None
56
+ self._logger = logging.getLogger(__name__)
57
+
58
+ async def run_if_due(self) -> None:
59
+ """Run one pruning pass when `interval_seconds` have passed since the last one."""
60
+ now = self._clock.now()
61
+ if self._next_run is not None and now < self._next_run:
62
+ return
63
+ await self._prune(now)
64
+ self._next_run = now + timedelta(seconds=self._settings.interval_seconds)
65
+
66
+ async def _prune(self, now: datetime) -> None:
67
+ delivered_up_to = self._cursor.read()
68
+ if delivered_up_to is None:
69
+ return
70
+ horizon = now - timedelta(days=self._settings.finished_task_days)
71
+ settled = [
72
+ task_id
73
+ for task_id in self._store.settled_task_ids(horizon)
74
+ if self._releasable(task_id)
75
+ ]
76
+ await asyncio.to_thread(_remove_run_dirs, [run_dir(self._project_root, t) for t in settled])
77
+ events = self._store.delete_task_events(settled, delivered_up_to=delivered_up_to)
78
+ events += self._store.delete_engine_events(
79
+ now - timedelta(days=self._settings.event_days), delivered_up_to=delivered_up_to
80
+ )
81
+ commands = self._store.delete_finished_commands(
82
+ now - timedelta(days=self._settings.command_days)
83
+ )
84
+ if settled or events or commands:
85
+ self._logger.info(
86
+ "retention pruned %d tasks, %d events, %d commands", len(settled), events, commands
87
+ )
88
+
89
+ def _releasable(self, task_id: str) -> bool:
90
+ task = self._tasks.get(task_id)
91
+ return (
92
+ task is not None
93
+ and not task.held
94
+ and not task_root(self._project_root, task_id).exists()
95
+ and not (task.status is Status.CANCELLED and self._has_open_pull_request(task_id))
96
+ )
97
+
98
+
99
+ def _remove_run_dirs(directories: list[Path]) -> None:
100
+ for directory in directories:
101
+ if directory.exists():
102
+ shutil.rmtree(directory)
@@ -0,0 +1,86 @@
1
+ """Off-tick task root preparation under a per-tick budget."""
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
+ if TYPE_CHECKING:
11
+ from collections.abc import Collection
12
+ from datetime import datetime
13
+
14
+ from shikiru.clock import Clock
15
+ from shikiru.domain.tasks import RepoState
16
+ from shikiru.workspace.roots import TaskRootBuilder
17
+
18
+
19
+ class RootPreparation:
20
+ """Builds task roots on their own tasks so a slow fetch or cache copy never holds the tick.
21
+
22
+ A tick may wait `budget_seconds` in total for the roots it starts; a root that is not ready
23
+ by then keeps building and is collected by a later tick, which never waits for it again. At most
24
+ one build runs at a time and none starts once a tick's budget is spent, so a hung remote costs
25
+ one budget, not one per ready task and tick, and builds in one repository never overlap.
26
+ """
27
+
28
+ def __init__(self, roots: TaskRootBuilder, clock: Clock, *, budget_seconds: float) -> None:
29
+ """Build through `roots`, measuring each tick's budget on `clock`."""
30
+ self._roots = roots
31
+ self._clock = clock
32
+ self._budget = timedelta(seconds=budget_seconds)
33
+ self._expires: datetime = clock.now()
34
+ self._builds: dict[str, asyncio.Task[RepoState]] = {}
35
+
36
+ def begin_tick(self, queued: Collection[str]) -> None:
37
+ """Start a tick's budget and let go of the builds of tasks that are no longer `queued`.
38
+
39
+ A build in flight for such a task (cancelled meanwhile) is cancelled, and counts as
40
+ running until it has ended; a finished one is dropped.
41
+ """
42
+ self._expires = self._clock.now() + self._budget
43
+ for task_id, build in tuple(self._builds.items()):
44
+ if task_id in queued:
45
+ continue
46
+ if not build.done():
47
+ build.cancel()
48
+ continue
49
+ del self._builds[task_id]
50
+ if not build.cancelled():
51
+ build.exception()
52
+
53
+ async def prepare(self, task_id: str, title: str, seed_branch: str | None) -> RepoState | None:
54
+ """Return `task_id`'s built root, or `None` while it is not ready within this tick.
55
+
56
+ Raises what the build raised.
57
+ """
58
+ build = self._builds.get(task_id)
59
+ if build is None:
60
+ remaining = (self._expires - self._clock.now()).total_seconds()
61
+ if remaining <= 0 or any(not other.done() for other in self._builds.values()):
62
+ return None
63
+ build = asyncio.create_task(self._roots.build(task_id, title, seed_branch=seed_branch))
64
+ self._builds[task_id] = build
65
+ await self._wait(build, remaining)
66
+ if not build.done():
67
+ return None
68
+ del self._builds[task_id]
69
+ return build.result()
70
+
71
+ async def shutdown(self) -> None:
72
+ """Cancel every build in flight and wait until its processes are gone."""
73
+ builds = tuple(self._builds.values())
74
+ self._builds.clear()
75
+ for build in builds:
76
+ build.cancel()
77
+ await asyncio.gather(*builds, return_exceptions=True)
78
+
79
+ async def _wait(self, build: asyncio.Task[RepoState], seconds: float) -> None:
80
+ timer = asyncio.create_task(self._clock.sleep(seconds))
81
+ try:
82
+ await asyncio.wait({build, timer}, return_when=asyncio.FIRST_COMPLETED)
83
+ finally:
84
+ timer.cancel()
85
+ with suppress(asyncio.CancelledError):
86
+ await timer