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,320 @@
1
+ """Task dispatch, gate evaluation and slot ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import logging
7
+ from collections.abc import Awaitable, Callable
8
+ from datetime import timedelta
9
+ from typing import TYPE_CHECKING
10
+
11
+ from shikiru.domain.events import EventKind
12
+ from shikiru.domain.faults import next_backoff
13
+ from shikiru.domain.ladder import move_up, pick_rung
14
+ from shikiru.domain.scheduling import (
15
+ Dispatch,
16
+ ReadySchedule,
17
+ SchedulingGates,
18
+ WaitReason,
19
+ occupied_slot_ids,
20
+ pick_ready,
21
+ task_agents,
22
+ )
23
+ from shikiru.domain.steps import Transition
24
+ from shikiru.domain.tasks import Role, Status, StatusReason, Step, Task
25
+ from shikiru.engine.events import emit_step_started
26
+ from shikiru.errors import InvariantError
27
+ from shikiru.workspace.base_refs import BaseFetchFailed
28
+
29
+ if TYPE_CHECKING:
30
+ from datetime import datetime
31
+
32
+ from shikiru.clock import Clock
33
+ from shikiru.config.settings import Settings
34
+ from shikiru.engine.fault_reporter import FaultReporter
35
+ from shikiru.engine.layout_guard import LayoutGuard
36
+ from shikiru.engine.quota_reconciler import QuotaReconciler
37
+ from shikiru.engine.root_preparation import RootPreparation
38
+ from shikiru.engine.titles import TaskTitles
39
+ from shikiru.store.events import EventLog
40
+ from shikiru.store.quotas import QuotaStore
41
+ from shikiru.store.tasks import TaskStore
42
+ from shikiru.telemetry.resources import ResourceSampler
43
+
44
+ DependencyCallback = Callable[[Task], Awaitable[None]]
45
+ DispatchFailureCallback = Callable[[Task, Exception], Awaitable[None]]
46
+ MAX_DISPATCH_FAILURES = 3
47
+ FETCH_FAULT_SCOPE = "dispatch fetch"
48
+ _QUOTA_DECORATED_STATUSES = (Status.QUEUED, Status.RUNNING, Status.INTEGRATING)
49
+
50
+
51
+ class Scheduler:
52
+ """Builds the ready set, persists wait reasons and dispatches eligible tasks."""
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ tasks: TaskStore,
58
+ settings: Settings,
59
+ quotas: QuotaStore,
60
+ reconciler: QuotaReconciler,
61
+ resources: ResourceSampler,
62
+ clock: Clock,
63
+ layout: LayoutGuard,
64
+ roots: RootPreparation,
65
+ events: EventLog,
66
+ on_dependency_cancelled: DependencyCallback,
67
+ on_dispatch_failure: DispatchFailureCallback,
68
+ titles: TaskTitles,
69
+ faults: FaultReporter,
70
+ ) -> None:
71
+ """Inject state stores, gates, and the task-window dispatch boundary.
72
+
73
+ A dispatch whose base fetch fails is retried on a growing backoff, recorded through
74
+ `faults`, and never counts toward `MAX_DISPATCH_FAILURES`.
75
+ """
76
+ self._tasks = tasks
77
+ self._settings = settings
78
+ self._quotas = quotas
79
+ self._reconciler = reconciler
80
+ self._resources = resources
81
+ self._clock = clock
82
+ self._layout = layout
83
+ self._roots = roots
84
+ self._events = events
85
+ self._on_dependency_cancelled = on_dependency_cancelled
86
+ self._on_dispatch_failure = on_dispatch_failure
87
+ self._titles = titles
88
+ self._faults = faults
89
+ self._dispatch_failures: dict[str, int] = {}
90
+ self._fetch_backoffs: dict[str, float] = {}
91
+ self._fetch_retry_at: dict[str, datetime] = {}
92
+ self._logger = logging.getLogger(__name__)
93
+
94
+ async def run_once(self) -> ReadySchedule:
95
+ """Evaluate gates, persist wait reasons and dispatch every eligible task."""
96
+ all_tasks = self._tasks.list_all()
97
+ self._request_quota_refreshes(all_tasks)
98
+ gates = self._gates(all_tasks)
99
+ schedule = pick_ready(all_tasks, gates)
100
+ if self._advance_quota_blocked(all_tasks, schedule):
101
+ all_tasks = self._tasks.list_all()
102
+ schedule = pick_ready(all_tasks, self._gates(all_tasks))
103
+ self._roots.begin_tick({task.id for task in all_tasks if task.status is Status.QUEUED})
104
+ self._persist_wait_reasons(all_tasks, schedule)
105
+ await self._sync_quota_decorations()
106
+ for task_id in schedule.dependency_cancelled:
107
+ task = self._tasks.get(task_id)
108
+ if task is None:
109
+ message = f"{task_id} vanished before dependency cancellation"
110
+ raise InvariantError(message)
111
+ await self._on_dependency_cancelled(task)
112
+ successful_dispatches: list[Dispatch] = []
113
+ for dispatch in schedule.dispatches:
114
+ task = self._tasks.get(dispatch.task_id)
115
+ if task is None:
116
+ message = f"{dispatch.task_id} vanished before dispatch"
117
+ raise InvariantError(message)
118
+ if self._awaits_fetch_retry(task.id):
119
+ continue
120
+ if await self._dispatch(task, dispatch.slot):
121
+ successful_dispatches.append(dispatch)
122
+ return dataclasses.replace(schedule, dispatches=tuple(successful_dispatches))
123
+
124
+ async def shutdown(self) -> None:
125
+ """Stop the quota probes and the task root builds still in flight."""
126
+ await self._reconciler.shutdown()
127
+ await self._roots.shutdown()
128
+
129
+ def _request_quota_refreshes(self, tasks: list[Task]) -> None:
130
+ now = self._clock.now()
131
+ demands: dict[str, datetime | None] = {}
132
+ for block in self._quotas.active_blocks(now):
133
+ queued = [
134
+ task
135
+ for task in tasks
136
+ if task.status is Status.QUEUED
137
+ and block.agent in dataclasses.astuple(task_agents(task, self._settings))
138
+ ]
139
+ if queued:
140
+ requeues = [task.requeued_at for task in queued if task.requeued_at is not None]
141
+ demands[block.agent] = max(requeues, default=None)
142
+ for agent, demanded_at in demands.items():
143
+ self._reconciler.request_refresh(agent, demanded_at=demanded_at)
144
+
145
+ def _advance_quota_blocked(self, tasks: list[Task], schedule: ReadySchedule) -> bool:
146
+ now = self._clock.now()
147
+ blocks = {block.agent: block for block in self._quotas.active_blocks(now)}
148
+ blocked_agents = frozenset(blocks)
149
+ wait_limit = now + timedelta(seconds=self._settings.orchestration.timeouts.max_quota_wait)
150
+ changed = False
151
+ for queued in tasks:
152
+ if schedule.wait_reasons.get(queued.id) is not WaitReason.QUOTA:
153
+ continue
154
+ task = queued
155
+ for role in (Role.LEADER, Role.IMPLEMENTER):
156
+ old_rung = pick_rung(task, role, self._settings)
157
+ block = blocks.get(old_rung.agent)
158
+ if block is None or block.until <= wait_limit:
159
+ continue
160
+ if self._reconciler.awaiting_demanded_probe(block):
161
+ continue
162
+ move = move_up(
163
+ task, role, self._settings, different_agent=True, blocked_agents=blocked_agents
164
+ )
165
+ if move is None:
166
+ continue
167
+ transition = dataclasses.replace(
168
+ Transition.stay(task, step=task.step, status=task.status),
169
+ counters=move.counters,
170
+ rung_index=move.rung_index,
171
+ cleared_overrides=move.cleared_overrides,
172
+ )
173
+ self._tasks.apply_transition(task.id, transition)
174
+ task = self._tasks.require(task.id)
175
+ new_rung = pick_rung(task, role, self._settings)
176
+ self._events.append(
177
+ EventKind.RUNG_CHANGED,
178
+ task_id=task.id,
179
+ message=(
180
+ f"{task.id} {role.value} moved from {old_rung.model} to {new_rung.model}"
181
+ ),
182
+ data={
183
+ "role": role.value,
184
+ "from": old_rung.model,
185
+ "to": new_rung.model,
186
+ "different_agent": True,
187
+ "reason": WaitReason.QUOTA.value,
188
+ },
189
+ )
190
+ changed = True
191
+ return changed
192
+
193
+ async def _sync_quota_decorations(self) -> None:
194
+ for task in self._tasks.list_all():
195
+ if task.window_id is not None and task.status in _QUOTA_DECORATED_STATUSES:
196
+ await self._titles.sync_quota(task)
197
+
198
+ def _gates(self, tasks: list[Task]) -> SchedulingGates:
199
+ slots = frozenset(range(self._settings.orchestration.max_parallel)) - occupied_slot_ids(
200
+ tasks
201
+ )
202
+ blocked = frozenset(block.agent for block in self._quotas.active_blocks(self._clock.now()))
203
+ agents = {
204
+ task.id: task_agents(task, self._settings)
205
+ for task in tasks
206
+ if task.status is Status.QUEUED
207
+ }
208
+ return SchedulingGates(
209
+ free_slots=slots,
210
+ resources_ok=self._resources.resources_ok(self._settings.orchestration.resources),
211
+ blocked_agents=blocked,
212
+ agents=agents,
213
+ )
214
+
215
+ def _persist_wait_reasons(self, tasks: list[Task], schedule: ReadySchedule) -> None:
216
+ reasons = schedule.wait_reasons
217
+ for task in tasks:
218
+ if task.status is not Status.QUEUED:
219
+ continue
220
+ reason = reasons.get(task.id)
221
+ if task.wait_reason != (reason.value if reason is not None else None):
222
+ self._tasks.set_wait_reason(task.id, reason)
223
+
224
+ async def _dispatch(self, task: Task, slot: int) -> bool:
225
+ current = task
226
+ try:
227
+ repo = task.repo
228
+ if repo is None:
229
+ repo = await self._roots.prepare(task.id, task.title, self._seed_branch(task))
230
+ if repo is None:
231
+ return False
232
+ self._tasks.set_slot(task.id, slot)
233
+ self._tasks.set_wait_reason(task.id, None)
234
+ status = Status.INTEGRATING if task.step is Step.INTEGRATE else Status.RUNNING
235
+ self._tasks.apply_transition(
236
+ task.id, Transition.stay(task, step=task.step, status=status)
237
+ )
238
+ current = self._require_task(task.id, "slot assignment")
239
+ if current.repo is None:
240
+ self._tasks.set_repo(current.id, repo)
241
+ current = self._require_task(task.id, "task-root creation")
242
+ await self._layout.ensure(current)
243
+ current = self._require_task(task.id, "layout creation")
244
+ await self._titles.refresh(current)
245
+ except BaseFetchFailed as error:
246
+ self._logger.warning("dispatch of %s waits for the remote: %s", task.id, error)
247
+ await self._rollback_dispatch(task)
248
+ self._back_off_fetch(task.id, error)
249
+ return False
250
+ except Exception as error:
251
+ self._logger.exception("dispatch setup failed for %s", task.id)
252
+ await self._rollback_dispatch(task)
253
+ failures = self._dispatch_failures.get(task.id, 0) + 1
254
+ self._dispatch_failures[task.id] = failures
255
+ self._events.append(
256
+ EventKind.DISPATCH_FAILED,
257
+ task_id=task.id,
258
+ message=f"{task.id} dispatch failed",
259
+ data={"attempt": failures, "error": str(error)},
260
+ )
261
+ if failures >= MAX_DISPATCH_FAILURES:
262
+ current = self._require_task(task.id, "dispatch failure")
263
+ await self._on_dispatch_failure(current, error)
264
+ self._dispatch_failures.pop(task.id, None)
265
+ return False
266
+ self._dispatch_failures.pop(task.id, None)
267
+ self._fetch_backoffs.pop(task.id, None)
268
+ self._fetch_retry_at.pop(task.id, None)
269
+ self._faults.resolve(FETCH_FAULT_SCOPE, task_id=task.id)
270
+ self._events.append(
271
+ EventKind.TASK_STARTED,
272
+ task_id=current.id,
273
+ message=f"{current.id} started in slot {slot}",
274
+ data={"slot": slot},
275
+ )
276
+ emit_step_started(self._events, current, self._settings)
277
+ return True
278
+
279
+ def _awaits_fetch_retry(self, task_id: str) -> bool:
280
+ retry_at = self._fetch_retry_at.get(task_id)
281
+ return retry_at is not None and self._clock.now() < retry_at
282
+
283
+ def _back_off_fetch(self, task_id: str, error: BaseFetchFailed) -> None:
284
+ faults = self._settings.orchestration.faults
285
+ delay = next_backoff(
286
+ self._fetch_backoffs.get(task_id, 0.0),
287
+ initial=faults.backoff_initial_seconds,
288
+ maximum=faults.backoff_max_seconds,
289
+ )
290
+ self._fetch_backoffs[task_id] = delay
291
+ self._fetch_retry_at[task_id] = self._clock.now() + timedelta(seconds=delay)
292
+ self._faults.report(error, scope=FETCH_FAULT_SCOPE, task_id=task_id)
293
+
294
+ def _seed_branch(self, task: Task) -> str | None:
295
+ parent = self._tasks.get(task.parent_id) if task.parent_id is not None else None
296
+ if parent is None or parent.status_reason is not StatusReason.SUPERSEDED:
297
+ return None
298
+ return parent.repo.branch if parent.repo is not None else None
299
+
300
+ async def _rollback_dispatch(self, task: Task) -> None:
301
+ current = self._tasks.get(task.id)
302
+ if current is None:
303
+ return
304
+ if task.window_id is None and current.window_id is not None:
305
+ try:
306
+ await self._layout.close(current)
307
+ except Exception:
308
+ self._logger.exception("dispatch cleanup failed for %s", task.id)
309
+ self._tasks.apply_transition(
310
+ current.id, Transition.stay(current, step=current.step, status=Status.QUEUED)
311
+ )
312
+ self._tasks.set_slot(current.id, None)
313
+ self._tasks.set_wait_reason(current.id, None)
314
+
315
+ def _require_task(self, task_id: str, operation: str) -> Task:
316
+ current = self._tasks.get(task_id)
317
+ if current is None:
318
+ message = f"{task_id} vanished after {operation}"
319
+ raise InvariantError(message)
320
+ return current
@@ -0,0 +1,46 @@
1
+ """Recreates the project's tmux session after the tmux server or session is lost."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Sequence
9
+ from logging import Logger
10
+ from pathlib import Path
11
+
12
+ from shikiru.tmux.client import Tmux
13
+ from shikiru.tmux.session import SessionView
14
+
15
+
16
+ class SessionGuard:
17
+ """Brings back the session and its home window when they have vanished."""
18
+
19
+ def __init__(
20
+ self,
21
+ *,
22
+ tmux: Tmux,
23
+ view: SessionView,
24
+ session_target: str,
25
+ project_name: str,
26
+ project_root: Path,
27
+ home_command: Sequence[str],
28
+ logger: Logger,
29
+ ) -> None:
30
+ """Probe `session_target` through `tmux` and recreate it through `view`."""
31
+ self._tmux = tmux
32
+ self._view = view
33
+ self._session_target = session_target
34
+ self._project_name = project_name
35
+ self._project_root = project_root
36
+ self._home_command = home_command
37
+ self._logger = logger
38
+
39
+ async def ensure(self) -> None:
40
+ """Recreate the session with its home window when it does not exist."""
41
+ if await self._tmux.target_exists(self._session_target):
42
+ return
43
+ self._logger.warning("tmux session %s is gone; recreating it", self._session_target)
44
+ await self._view.ensure(
45
+ self._project_name, self._project_root, home_command=self._home_command
46
+ )
@@ -0,0 +1,95 @@
1
+ """Session selection for resumed process-per-turn agent sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.domain.ladder import pick_rung
10
+ from shikiru.domain.runs import Run, RunStatus
11
+ from shikiru.errors import InvariantError
12
+ from shikiru.paths import turns_dir
13
+ from shikiru.workspace.reports import load_turn_report
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ from shikiru.agents.contracts import Agent, Rung
19
+ from shikiru.config.settings import Settings
20
+ from shikiru.domain.tasks import Role, Task
21
+ from shikiru.engine.agents import AgentPool
22
+ from shikiru.store.runs import RunStore
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SessionSelection:
27
+ """The rung, resumable session and run row selected for one role turn."""
28
+
29
+ rung: Rung
30
+ session_id: str | None
31
+ handoff: bool
32
+ run: Run
33
+
34
+
35
+ class SessionManager:
36
+ """Select and persist the session lineage for the next turn of a task role."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ runs: RunStore,
42
+ agents: AgentPool,
43
+ project_root: Path,
44
+ settings: Settings,
45
+ ) -> None:
46
+ self._runs = runs
47
+ self._agents = agents
48
+ self._project_root = project_root
49
+ self._settings = settings
50
+
51
+ def session_for(self, task: Task, role: Role) -> SessionSelection:
52
+ """Choose a session, resuming it across turns and same-agent rung changes."""
53
+ rung = pick_rung(task, role, self._settings)
54
+ current = self._runs.get_live(task.id, role)
55
+ if current is not None and current.agent == rung.agent:
56
+ self._runs.set_rung(current.id, rung)
57
+ refreshed = self._runs.get_live(task.id, role)
58
+ if refreshed is None:
59
+ message = f"{task.id} {role.value} run disappeared during session selection"
60
+ raise InvariantError(message)
61
+ return SessionSelection(rung, refreshed.session_id, False, refreshed)
62
+
63
+ if current is not None:
64
+ self._runs.close(current.id)
65
+ history_exists = self._has_reports(task.id)
66
+ run = self._runs.start(
67
+ task.id,
68
+ role,
69
+ rung,
70
+ billing_declaration=self._agents.billing_declaration(rung.agent),
71
+ )
72
+ return SessionSelection(rung, None, history_exists, run)
73
+
74
+ def record_session_started(
75
+ self, *, run_id: int, agent: Agent, session_id: str, run_dir: Path, rung: Rung
76
+ ) -> None:
77
+ """Persist the session id and the command an operator can use to resume it."""
78
+ command = shlex.join(agent.interactive_command(session_id, run_dir, rung))
79
+ self._runs.set_session(run_id, session_id, command)
80
+
81
+ def close_task(self, task_id: str) -> None:
82
+ """Close every role session after its task ends."""
83
+ for run in self._runs.for_task(task_id):
84
+ if run.status is not RunStatus.CLOSED:
85
+ self._runs.close(run.id)
86
+
87
+ def _has_reports(self, task_id: str) -> bool:
88
+ task_turns_dir = turns_dir(self._project_root, task_id)
89
+ if not task_turns_dir.is_dir():
90
+ return False
91
+ return any(
92
+ load_turn_report(turn_dir) is not None
93
+ for turn_dir in task_turns_dir.iterdir()
94
+ if turn_dir.is_dir()
95
+ )
@@ -0,0 +1,75 @@
1
+ """Runs the engine so that SIGTERM and SIGINT stop it gracefully, within a bound."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import signal
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.errors import ShikiruError
10
+
11
+ if TYPE_CHECKING:
12
+ from shikiru.clock import Clock
13
+ from shikiru.engine.daemon import Engine
14
+ from shikiru.engine.turn_processes import TurnProcesses
15
+
16
+ STOP_SIGNALS = (signal.SIGTERM, signal.SIGINT)
17
+
18
+
19
+ class ForcedShutdownError(ShikiruError):
20
+ """The engine was signalled twice or outlived its shutdown bound and was ended by force."""
21
+
22
+
23
+ class SignalledEngine:
24
+ """Run an `Engine` until it stops, turning the first stop signal into `Engine.request_stop`."""
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ engine: Engine,
30
+ turn_processes: TurnProcesses,
31
+ clock: Clock,
32
+ shutdown_seconds: float,
33
+ ) -> None:
34
+ """Bound the graceful shutdown by `shutdown_seconds` on `clock`."""
35
+ self._engine = engine
36
+ self._turn_processes = turn_processes
37
+ self._clock = clock
38
+ self._shutdown_seconds = shutdown_seconds
39
+ self._deadline: asyncio.Task[None] | None = None
40
+ self._forced = False
41
+
42
+ async def run(self) -> None:
43
+ """Run the engine; a second signal or the bound cancels it and ends every turn process."""
44
+ loop = asyncio.get_running_loop()
45
+ engine = asyncio.create_task(self._engine.run())
46
+ for signum in STOP_SIGNALS:
47
+ loop.add_signal_handler(signum, self._on_signal, engine)
48
+ try:
49
+ await engine
50
+ except asyncio.CancelledError:
51
+ if not self._forced:
52
+ raise
53
+ await self._turn_processes.end_orphans()
54
+ message = f"engine ended by force after {self._shutdown_seconds:g} s or a second signal"
55
+ raise ForcedShutdownError(message) from None
56
+ finally:
57
+ for signum in STOP_SIGNALS:
58
+ loop.remove_signal_handler(signum)
59
+ if self._deadline is not None:
60
+ self._deadline.cancel()
61
+
62
+ def _on_signal(self, engine: asyncio.Task[None]) -> None:
63
+ if self._deadline is not None:
64
+ self._force(engine)
65
+ return
66
+ self._engine.request_stop()
67
+ self._deadline = asyncio.create_task(self._expire(engine))
68
+
69
+ async def _expire(self, engine: asyncio.Task[None]) -> None:
70
+ await self._clock.sleep(self._shutdown_seconds)
71
+ self._force(engine)
72
+
73
+ def _force(self, engine: asyncio.Task[None]) -> None:
74
+ self._forced = True
75
+ engine.cancel()
@@ -0,0 +1,140 @@
1
+ """Runs a step's turn to its transition, the boundary every turn shares."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.recovery import ProtocolError, TurnCounters
8
+ from shikiru.domain.reports import FeedbackAnalysis, ImplementationReport, Verdict
9
+ from shikiru.domain.steps import Transition, next_step
10
+ from shikiru.domain.tasks import EscalationReason, Status, Step
11
+ from shikiru.engine.events import emit_turn_failed
12
+ from shikiru.engine.turn_loop import TurnOverrides, TurnsStopped
13
+ from shikiru.errors import InvariantError, UsageError
14
+
15
+ if TYPE_CHECKING:
16
+ from shikiru.config.settings import Settings
17
+ from shikiru.domain.feedback import FeedbackInput
18
+ from shikiru.domain.reports import Report, ReportKind
19
+ from shikiru.domain.steps import Outcome
20
+ from shikiru.domain.tasks import Role, Task
21
+ from shikiru.engine.acceptance import AcceptanceGuard
22
+ from shikiru.engine.escalation_filing import EscalationFiler
23
+ from shikiru.engine.transitions import TransitionWriter
24
+ from shikiru.engine.turn_execution import TurnExecution
25
+ from shikiru.engine.turn_loop import TurnLoop
26
+ from shikiru.prompts.renderer import PromptName
27
+ from shikiru.store.events import EventLog
28
+
29
+
30
+ class TurnStopped(Exception):
31
+ """Stop a caller's sequence after its task escalates or pauses."""
32
+
33
+
34
+ class StepRunner:
35
+ """Runs a step's turn, settles its report and applies the transition the report earns."""
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ loop: TurnLoop,
41
+ acceptance: AcceptanceGuard,
42
+ escalations: EscalationFiler,
43
+ writer: TransitionWriter,
44
+ events: EventLog,
45
+ settings: Settings,
46
+ ) -> None:
47
+ """Run turns through `loop`, guard acceptance, then transition or escalate."""
48
+ self._loop = loop
49
+ self._acceptance = acceptance
50
+ self._escalations = escalations
51
+ self._writer = writer
52
+ self._events = events
53
+ self._settings = settings
54
+
55
+ async def run_step(
56
+ self,
57
+ task: Task,
58
+ step: Step,
59
+ counters: TurnCounters,
60
+ *,
61
+ conflict_context: str | None = None,
62
+ force_new_turn: bool = False,
63
+ ) -> tuple[Task, TurnCounters, bool]:
64
+ """Run one domain step through its turn and recovery loop; report whether it stopped."""
65
+ if force_new_turn:
66
+ self._loop.forget_resume(task.id)
67
+ result = await self._loop.run(
68
+ task, step, counters, TurnOverrides(conflict_context=conflict_context)
69
+ )
70
+ if isinstance(result, TurnsStopped):
71
+ return result.task, result.counters, True
72
+ outcome = await self._acceptance.settle(result.task, result.report, result.execution)
73
+ try:
74
+ current, escalated = await self.advance(result.task, outcome)
75
+ except UsageError as error:
76
+ escalated_task = await self._escalate_scope_change_protocol(
77
+ result.task,
78
+ step,
79
+ outcome,
80
+ error,
81
+ execution=result.execution,
82
+ )
83
+ return escalated_task, TurnCounters(), True
84
+ return current, TurnCounters(), escalated
85
+
86
+ async def run_report_turn(
87
+ self,
88
+ task: Task,
89
+ *,
90
+ role: Role,
91
+ template: PromptName,
92
+ kind: ReportKind,
93
+ feedback: FeedbackInput | None = None,
94
+ ) -> tuple[Task, ImplementationReport | Verdict | FeedbackAnalysis]:
95
+ """Run one integration report through the shared turn and recovery loop."""
96
+ result = await self._loop.run(
97
+ task,
98
+ Step.INTEGRATE,
99
+ TurnCounters(),
100
+ TurnOverrides(role=role, template=template, kind=kind, feedback=feedback),
101
+ )
102
+ if isinstance(result, TurnsStopped):
103
+ raise TurnStopped
104
+ report: Report = result.report
105
+ if not isinstance(report, ImplementationReport | Verdict | FeedbackAnalysis):
106
+ message = f"{result.task.id} integration turn returned an unexpected report"
107
+ raise InvariantError(message)
108
+ return result.task, await self._acceptance.settle(result.task, report, result.execution)
109
+
110
+ async def advance(
111
+ self, task: Task, outcome: Outcome, *, role: Role | None = None
112
+ ) -> tuple[Task, bool]:
113
+ """Apply the transition `outcome` earns `task`; report whether it escalated."""
114
+ transition = next_step(task, outcome, self._settings)
115
+ if transition.status is Status.ESCALATED:
116
+ escalated = await self._escalations.escalate(task, transition, outcome, role=role)
117
+ return escalated, True
118
+ return await self._writer.apply(task, transition, outcome), False
119
+
120
+ async def _escalate_scope_change_protocol(
121
+ self,
122
+ task: Task,
123
+ step: Step,
124
+ outcome: Outcome,
125
+ error: UsageError,
126
+ *,
127
+ execution: TurnExecution,
128
+ ) -> Task:
129
+ emit_turn_failed(
130
+ self._events,
131
+ task,
132
+ step,
133
+ ProtocolError(),
134
+ rung=execution.rung,
135
+ run_id=execution.run_id,
136
+ turn=execution.turn_dir.name,
137
+ role=execution.role,
138
+ )
139
+ transition = Transition.escalate(task, EscalationReason.AGENT_PROTOCOL, step=step)
140
+ return await self._escalations.escalate(task, transition, outcome, question=str(error))