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,368 @@
1
+ """Resolve bounded state and current-turn signal evidence for dashboard frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.dashboard.tick import TickMemo
8
+ from shikiru.domain.events import EventKind
9
+ from shikiru.domain.motion import (
10
+ MotionStamp,
11
+ MotionStampKind,
12
+ MotionState,
13
+ QuotaMotionState,
14
+ TaskMotionState,
15
+ )
16
+ from shikiru.domain.motion import (
17
+ frame as motion_frame,
18
+ )
19
+ from shikiru.domain.reports import CURRENT_TURN_LINK_NAME
20
+ from shikiru.domain.runs import RunStatus
21
+ from shikiru.domain.steps import ACTING_ROLE, Step
22
+ from shikiru.domain.tasks import Role, Status, Task
23
+ from shikiru.paths import run_dir
24
+ from shikiru.view_log import TURN_DIR_ITERATION_MARKER, TURN_DIR_SEPARATOR, view_log_path
25
+
26
+ if TYPE_CHECKING:
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+
30
+ from shikiru.dashboard.activity import RoleViewTail
31
+ from shikiru.dashboard.live_settings import LiveSettings
32
+ from shikiru.store.reader import StateReader
33
+
34
+ _MILLISECONDS_PER_SECOND = 1000
35
+ _ENGINE_DRIVEN_STEPS = (None, Step.INTEGRATE)
36
+ _TASK_EVENT_KINDS = (
37
+ EventKind.STEP_STARTED,
38
+ EventKind.TURN_STARTED,
39
+ EventKind.TURN_COMPLETED,
40
+ EventKind.TURN_FAILED,
41
+ EventKind.TURN_STOPPED,
42
+ EventKind.VERDICT,
43
+ EventKind.INTEGRATION_WAITING,
44
+ )
45
+
46
+
47
+ class DashboardMotion:
48
+ """Build time-derived frames from cached events and incremental current-turn logs."""
49
+
50
+ def __init__(
51
+ self,
52
+ state: StateReader,
53
+ project_root: Path,
54
+ settings: LiveSettings,
55
+ role_view_tail: RoleViewTail,
56
+ ) -> None:
57
+ """Use the read-only state, configuration and injected role-log tail."""
58
+ self._state = state
59
+ self._project_root = project_root
60
+ self._settings = settings
61
+ self._role_view_tail = role_view_tail
62
+ self._event_version = -1
63
+ self._task_events: dict[str, tuple[Event, ...]] = {}
64
+ self._task_runs: dict[str, tuple[Run, ...]] = {}
65
+ self._merge_events: tuple[Event, ...] = ()
66
+ self._tick_memo = TickMemo(state.data_version)
67
+
68
+ def recent_merges(self, now: datetime) -> list[Task]:
69
+ """Return completed tasks whose merge stamp is still within its flash window."""
70
+ self._sync_event_cache()
71
+ if not self._settings.current().dashboard.animations:
72
+ return []
73
+ flash_seconds = (
74
+ self._settings.current().dashboard.motion.stamp_flash_milliseconds
75
+ / _MILLISECONDS_PER_SECOND
76
+ )
77
+ tasks: list[Task] = []
78
+ for event in reversed(self._merge_events):
79
+ age = (now - event.ts).total_seconds()
80
+ if event.task_id is None or age < 0 or age >= flash_seconds:
81
+ continue
82
+ task = self._state.get_task(event.task_id)
83
+ if task is not None and task.status is Status.DONE:
84
+ tasks.append(task)
85
+ return tasks
86
+
87
+ def frames(
88
+ self,
89
+ tasks: tuple[Task, ...],
90
+ now: datetime,
91
+ *,
92
+ quotas: tuple[QuotaMotionState, ...] = (),
93
+ escalations_open: bool = False,
94
+ engine_running: bool = True,
95
+ ) -> MotionFrame:
96
+ """Resolve task, quota and escalation frames at one injected time value."""
97
+ self._sync_event_cache()
98
+ return motion_frame(
99
+ self._motion_state(
100
+ tasks=tuple(self._shared_task_motion(task) for task in tasks),
101
+ quotas=quotas,
102
+ escalations_open=escalations_open,
103
+ engine_running=engine_running,
104
+ ),
105
+ now,
106
+ )
107
+
108
+ def current_cost(self, task: Task) -> float | None:
109
+ """Return the acting run's latest cost when that run is known."""
110
+ if task.step is None:
111
+ return None
112
+ run = self._run_for_role(task, ACTING_ROLE[task.step])
113
+ return run.usage.cost_usd if run is not None else None
114
+
115
+ def _sync_event_cache(self) -> None:
116
+ version = self._state.data_version()
117
+ if version == self._event_version:
118
+ return
119
+ self._event_version = version
120
+ self._task_events.clear()
121
+ self._task_runs.clear()
122
+ self._merge_events = tuple(
123
+ self._state.latest_events(
124
+ self._settings.current().dashboard.motion.merge_event_limit,
125
+ kinds=(EventKind.TASK_DONE,),
126
+ )
127
+ )
128
+
129
+ def _shared_task_motion(self, task: Task) -> TaskMotionState:
130
+ return self._tick_memo.get(task.id, lambda: self._task_motion(task))
131
+
132
+ def _task_motion(self, task: Task) -> TaskMotionState:
133
+ events = self._events_for(task)
134
+ step_started_at, actual_step_started_at, turn_started_at, turn_active, stamp = (
135
+ self._event_timing(task, events)
136
+ )
137
+ (
138
+ last_signal_at,
139
+ last_signal,
140
+ tool_running,
141
+ turn_active,
142
+ last_words,
143
+ ) = self._current_activity(task, turn_active, turn_started_at)
144
+ held = self._is_held(task)
145
+ engine_driven = self._engine_driven(task, events)
146
+ stamp = self._merge_stamp(task.id, stamp)
147
+ return TaskMotionState(
148
+ task_id=task.id,
149
+ step_started_at=step_started_at,
150
+ actual_step_started_at=actual_step_started_at,
151
+ turn_started_at=turn_started_at,
152
+ last_signal_at=last_signal_at,
153
+ last_signal=last_signal,
154
+ last_words=last_words,
155
+ tool_running=tool_running,
156
+ turn_active=turn_active,
157
+ engine_driven=engine_driven,
158
+ held=held,
159
+ ended_at=task.finished_at,
160
+ stamp=stamp,
161
+ )
162
+
163
+ @staticmethod
164
+ def _engine_driven(task: Task, events: tuple[Event, ...]) -> bool:
165
+ if task.step not in _ENGINE_DRIVEN_STEPS:
166
+ return False
167
+ for event in reversed(events):
168
+ if event.kind is EventKind.INTEGRATION_WAITING:
169
+ return False
170
+ if event.kind is EventKind.STEP_STARTED:
171
+ return True
172
+ return True
173
+
174
+ def _events_for(self, task: Task) -> tuple[Event, ...]:
175
+ events = self._task_events.get(task.id)
176
+ if events is None:
177
+ events = tuple(
178
+ self._state.latest_events(
179
+ self._settings.current().dashboard.motion.task_event_limit,
180
+ kinds=_TASK_EVENT_KINDS,
181
+ task_id=task.id,
182
+ )
183
+ )
184
+ self._task_events[task.id] = events
185
+ return events
186
+
187
+ @staticmethod
188
+ def _event_timing(
189
+ task: Task, events: tuple[Event, ...]
190
+ ) -> tuple[datetime, datetime | None, datetime | None, bool, MotionStamp | None]:
191
+ step_started_at = task.started_at or task.created_at
192
+ actual_step_started_at = None
193
+ turn_started_at = None
194
+ turn_active = False
195
+ stamp: MotionStamp | None = None
196
+ role = ACTING_ROLE.get(task.step) if task.step is not None else None
197
+ latest_stage_entry = next(
198
+ (event for event in reversed(events) if event.kind is EventKind.STEP_STARTED),
199
+ None,
200
+ )
201
+ if latest_stage_entry is not None:
202
+ step_value = latest_stage_entry.data.get("step")
203
+ iteration = latest_stage_entry.data.get("iteration")
204
+ try:
205
+ entry_step = Step(step_value) if isinstance(step_value, str) else None
206
+ except ValueError:
207
+ entry_step = None
208
+ if (
209
+ task.step is not None
210
+ and entry_step is task.step
211
+ and type(iteration) is int
212
+ and iteration == task.iteration
213
+ ):
214
+ actual_step_started_at = latest_stage_entry.ts
215
+ step_started_at = latest_stage_entry.ts
216
+ for event in events:
217
+ if event.kind is EventKind.VERDICT:
218
+ stamp = MotionStamp(
219
+ event.ts,
220
+ MotionStampKind.VERDICT,
221
+ str(event.data["decision"]),
222
+ )
223
+ if role is None or task.step is None:
224
+ continue
225
+ matching_turn = (
226
+ event.data.get("step") == task.step.value
227
+ and event.data.get("role") == role.value
228
+ and event.data.get("iteration") == task.iteration
229
+ )
230
+ if event.kind is EventKind.TURN_STARTED and matching_turn:
231
+ turn_started_at = event.ts
232
+ turn_active = True
233
+ elif (
234
+ event.kind in (EventKind.TURN_COMPLETED, EventKind.TURN_STOPPED) and matching_turn
235
+ ) or (
236
+ event.kind is EventKind.TURN_FAILED and event.data.get("step") == task.step.value
237
+ ):
238
+ turn_started_at = None
239
+ turn_active = False
240
+ return step_started_at, actual_step_started_at, turn_started_at, turn_active, stamp
241
+
242
+ def _current_activity(
243
+ self, task: Task, turn_active: bool, turn_started_at: datetime | None
244
+ ) -> tuple[datetime | None, str | None, bool, bool, str | None]:
245
+ role = ACTING_ROLE.get(task.step) if task.step is not None else None
246
+ last_signal_at = None
247
+ last_signal = None
248
+ tool_running = False
249
+ last_words = None
250
+ if role is None:
251
+ return (
252
+ last_signal_at,
253
+ last_signal,
254
+ tool_running,
255
+ False,
256
+ last_words,
257
+ )
258
+ turn_name = self._current_turn_name(task)
259
+ if turn_name is None:
260
+ return (
261
+ last_signal_at,
262
+ last_signal,
263
+ tool_running,
264
+ turn_active,
265
+ last_words,
266
+ )
267
+ activity = self._role_view_tail.read(
268
+ view_log_path(run_dir(self._project_root, task.id), role.value), turn_name
269
+ )
270
+ if activity is None or (
271
+ turn_started_at is not None and activity.last_signal_at < turn_started_at
272
+ ):
273
+ return (
274
+ last_signal_at,
275
+ last_signal,
276
+ tool_running,
277
+ turn_active,
278
+ last_words,
279
+ )
280
+ last_signal_at = activity.last_signal_at
281
+ last_signal = activity.last_signal
282
+ tool_running = activity.tool_running
283
+ last_words = activity.last_words
284
+ turn_active = turn_active and not activity.ended
285
+ return (
286
+ last_signal_at,
287
+ last_signal,
288
+ tool_running,
289
+ turn_active,
290
+ last_words,
291
+ )
292
+
293
+ def _is_held(self, task: Task) -> bool:
294
+ role = ACTING_ROLE.get(task.step) if task.step is not None else None
295
+ run = self._run_for_role(task, role) if role is not None else None
296
+ held = (
297
+ bool(task.holds)
298
+ or task.wait_reason is not None
299
+ or task.status not in (Status.RUNNING, Status.INTEGRATING)
300
+ )
301
+ if run is not None and run.status is RunStatus.TAKEN_OVER:
302
+ held = True
303
+ return held
304
+
305
+ def _merge_stamp(self, task_id: str, stamp: MotionStamp | None) -> MotionStamp | None:
306
+ merge = next(
307
+ (event for event in reversed(self._merge_events) if event.task_id == task_id), None
308
+ )
309
+ if merge is not None:
310
+ return MotionStamp(merge.ts, MotionStampKind.MERGED)
311
+ return stamp
312
+
313
+ def _current_turn_name(self, task: Task) -> str | None:
314
+ if task.step is None:
315
+ return None
316
+ role = ACTING_ROLE[task.step]
317
+ link = run_dir(self._project_root, task.id) / role.value / CURRENT_TURN_LINK_NAME
318
+ try:
319
+ if not link.is_symlink():
320
+ return None
321
+ turn_name = link.readlink().name
322
+ except FileNotFoundError:
323
+ return None
324
+ expected_suffix = (
325
+ f"{TURN_DIR_SEPARATOR}{task.step.value}{TURN_DIR_ITERATION_MARKER}{task.iteration}"
326
+ )
327
+ return turn_name if turn_name.endswith(expected_suffix) else None
328
+
329
+ def _run_for_role(self, task: Task, role: Role) -> Run | None:
330
+ runs = self._task_runs.get(task.id)
331
+ if runs is None:
332
+ runs = tuple(self._state.list_runs(task.id))
333
+ self._task_runs[task.id] = runs
334
+ return next(
335
+ (
336
+ run
337
+ for run in runs
338
+ if run.role is role and run.status in (RunStatus.LIVE, RunStatus.TAKEN_OVER)
339
+ ),
340
+ None,
341
+ )
342
+
343
+ def _motion_state(
344
+ self,
345
+ *,
346
+ tasks: tuple[TaskMotionState, ...] = (),
347
+ quotas: tuple[QuotaMotionState, ...] = (),
348
+ escalations_open: bool = False,
349
+ engine_running: bool = True,
350
+ ) -> MotionState:
351
+ config = self._settings.current().dashboard.motion
352
+ return MotionState(
353
+ animations=self._settings.current().dashboard.animations,
354
+ timeouts=self._settings.current().orchestration.timeouts,
355
+ refresh_hz=config.refresh_hz,
356
+ stamp_flash_seconds=config.stamp_flash_milliseconds / _MILLISECONDS_PER_SECOND,
357
+ escalation_pulse_hz=config.escalation_pulse_hz,
358
+ tasks=tasks,
359
+ quotas=quotas,
360
+ escalations_open=escalations_open,
361
+ engine_running=engine_running,
362
+ )
363
+
364
+
365
+ if TYPE_CHECKING:
366
+ from shikiru.domain.events import Event
367
+ from shikiru.domain.motion import MotionFrame
368
+ from shikiru.domain.runs import Run
@@ -0,0 +1,201 @@
1
+ """The orchestration inputs of the installation wizard: units, guidance and accepted values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.config.settings import MAX_USAGE_PCT
10
+ from shikiru.errors import ConfigError
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+ from shikiru.config.settings import OrchestrationConfig
16
+
17
+ MINIMUM_PARALLEL = 1
18
+ MINIMUM_ITERATIONS = 1
19
+ MINIMUM_PUSH_INTERVAL = 0
20
+ MINIMUM_QUOTA_WAIT = 0
21
+ MINIMUM_STALL_TIMEOUT = 1
22
+ MINIMUM_TOOL_TIMEOUT = 1
23
+ MINIMUM_CPU_PCT = 0
24
+ MINIMUM_FREE_RAM_GB = 0
25
+
26
+
27
+ class OrchestrationFieldError(ConfigError):
28
+ """A rejected orchestration value; `input_id` names the wizard input to correct."""
29
+
30
+ def __init__(self, input_id: str, message: str) -> None:
31
+ """Explain why the value typed into `input_id` cannot be written."""
32
+ super().__init__(message)
33
+ self.input_id = input_id
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class OrchestrationField:
38
+ """One orchestration input: the setting it edits, its unit, guidance and accepted values."""
39
+
40
+ input_id: str
41
+ label: str
42
+ summary: str
43
+ unit: str
44
+ explanation: str
45
+ default: Callable[[OrchestrationConfig], int | float]
46
+ minimum: float
47
+ maximum: float | None
48
+ whole_number: bool
49
+
50
+ def parse(self, raw: str) -> int | float:
51
+ """Return the number typed into the input, or raise `OrchestrationFieldError` for it."""
52
+ value = _whole(raw) if self.whole_number else _decimal(raw)
53
+ if value is None or value < self.minimum or self._above_maximum(value):
54
+ raise OrchestrationFieldError(self.input_id, self._rule())
55
+ return value
56
+
57
+ def _above_maximum(self, value: float) -> bool:
58
+ return self.maximum is not None and value > self.maximum
59
+
60
+ def _rule(self) -> str:
61
+ kind = "a whole number" if self.whole_number else "a number"
62
+ if self.maximum is None:
63
+ bounds = f"at least {self.minimum:g}"
64
+ else:
65
+ bounds = f"from {self.minimum:g} to {self.maximum:g}"
66
+ return f"{self.label} must be {kind} {bounds}."
67
+
68
+
69
+ def _whole(raw: str) -> int | None:
70
+ text = raw.strip()
71
+ if not text.isdecimal():
72
+ return None
73
+ return int(text)
74
+
75
+
76
+ def _decimal(raw: str) -> float | None:
77
+ try:
78
+ value = float(raw)
79
+ except ValueError:
80
+ return None
81
+ return value if math.isfinite(value) else None
82
+
83
+
84
+ MAX_PARALLEL = OrchestrationField(
85
+ input_id="max-parallel",
86
+ label="Maximum parallel tasks",
87
+ summary="Max parallel",
88
+ unit="tasks",
89
+ explanation="Task slots the engine fills at once; each running task uses one.",
90
+ default=lambda config: config.max_parallel,
91
+ minimum=MINIMUM_PARALLEL,
92
+ maximum=None,
93
+ whole_number=True,
94
+ )
95
+ MAX_ITERATIONS = OrchestrationField(
96
+ input_id="max-iterations",
97
+ label="Maximum iterations",
98
+ summary="Max iterations",
99
+ unit="passes",
100
+ explanation=(
101
+ "Implement and verify passes per task, the first included; "
102
+ "the pass that reaches this count is the last."
103
+ ),
104
+ default=lambda config: config.max_iterations,
105
+ minimum=MINIMUM_ITERATIONS,
106
+ maximum=None,
107
+ whole_number=True,
108
+ )
109
+ PUSH_INTERVAL = OrchestrationField(
110
+ input_id="push-interval",
111
+ label="Push interval",
112
+ summary="Push interval",
113
+ unit="seconds",
114
+ explanation=(
115
+ "How often locally integrated work is pushed to the remote; the default 14400 is "
116
+ "four hours, and 0 pushes after every integration."
117
+ ),
118
+ default=lambda config: config.push_interval_seconds,
119
+ minimum=MINIMUM_PUSH_INTERVAL,
120
+ maximum=None,
121
+ whole_number=True,
122
+ )
123
+ MAX_QUOTA_WAIT = OrchestrationField(
124
+ input_id="max-quota-wait",
125
+ label="Maximum quota wait",
126
+ summary="Max quota wait",
127
+ unit="seconds",
128
+ explanation=(
129
+ "The longest wait for an agent's usage quota to reset before the task moves to "
130
+ "another rung or escalates; 0 moves on at once."
131
+ ),
132
+ default=lambda config: config.timeouts.max_quota_wait,
133
+ minimum=MINIMUM_QUOTA_WAIT,
134
+ maximum=None,
135
+ whole_number=True,
136
+ )
137
+ STALL_TIMEOUT = OrchestrationField(
138
+ input_id="stall-timeout",
139
+ label="Stall timeout",
140
+ summary="Stall timeout",
141
+ unit="seconds",
142
+ explanation=(
143
+ "How long an agent may stay silent while no tool is running before its turn "
144
+ "is stopped as stalled."
145
+ ),
146
+ default=lambda config: config.timeouts.stall,
147
+ minimum=MINIMUM_STALL_TIMEOUT,
148
+ maximum=None,
149
+ whole_number=True,
150
+ )
151
+ TOOL_TIMEOUT = OrchestrationField(
152
+ input_id="tool-timeout",
153
+ label="Tool timeout",
154
+ summary="Tool timeout",
155
+ unit="seconds",
156
+ explanation=(
157
+ "How long an agent may stay silent while a tool is running before its turn "
158
+ "is stopped as stalled."
159
+ ),
160
+ default=lambda config: config.timeouts.tool,
161
+ minimum=MINIMUM_TOOL_TIMEOUT,
162
+ maximum=None,
163
+ whole_number=True,
164
+ )
165
+ MAX_CPU = OrchestrationField(
166
+ input_id="max-cpu",
167
+ label="Maximum CPU use",
168
+ summary="Max CPU",
169
+ unit="percent",
170
+ explanation=(
171
+ "New tasks start only while host CPU use is below this; 0 holds every new start, "
172
+ "100 holds only a fully busy host."
173
+ ),
174
+ default=lambda config: config.resources.max_cpu_pct,
175
+ minimum=MINIMUM_CPU_PCT,
176
+ maximum=MAX_USAGE_PCT,
177
+ whole_number=True,
178
+ )
179
+ MIN_FREE_RAM = OrchestrationField(
180
+ input_id="min-free-ram",
181
+ label="Minimum free RAM",
182
+ summary="Min free RAM",
183
+ unit="GB",
184
+ explanation=(
185
+ "New tasks start only while at least this much RAM is free; 0 turns the RAM check off."
186
+ ),
187
+ default=lambda config: config.resources.min_free_ram_gb,
188
+ minimum=MINIMUM_FREE_RAM_GB,
189
+ maximum=None,
190
+ whole_number=False,
191
+ )
192
+ ORCHESTRATION_FIELDS = (
193
+ MAX_PARALLEL,
194
+ MAX_ITERATIONS,
195
+ PUSH_INTERVAL,
196
+ MAX_QUOTA_WAIT,
197
+ STALL_TIMEOUT,
198
+ TOOL_TIMEOUT,
199
+ MAX_CPU,
200
+ MIN_FREE_RAM,
201
+ )