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,101 @@
1
+ """Supervise concurrent task flows for the engine daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import suppress
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.domain.tasks import Status, StatusReason
10
+
11
+ if TYPE_CHECKING:
12
+ from logging import Logger
13
+
14
+ from shikiru.engine.flow import TaskFlow
15
+ from shikiru.engine.lifecycle import TaskFinisher
16
+ from shikiru.store.tasks import TaskStore
17
+
18
+ _IN_FLIGHT = (Status.RUNNING, Status.INTEGRATING)
19
+
20
+
21
+ class TaskSupervisor:
22
+ """Start, interrupt and reap the task flows owned by one engine daemon."""
23
+
24
+ def __init__(self, tasks: TaskStore, flow: TaskFlow, finisher: TaskFinisher) -> None:
25
+ self._tasks = tasks
26
+ self._flow = flow
27
+ self._finisher = finisher
28
+ self._stop = asyncio.Event()
29
+ self._flow_tasks: dict[str, asyncio.Task[None]] = {}
30
+
31
+ @property
32
+ def stop_requested(self) -> bool:
33
+ return self._stop.is_set()
34
+
35
+ def request_stop(self) -> None:
36
+ """Ask the daemon command loop to stop after acknowledging the current command."""
37
+ self._stop.set()
38
+
39
+ async def start_task(self, task_id: str) -> None:
40
+ """Schedule a task flow unless one is still live; `ensure_flows` covers a finishing one."""
41
+ self._start(task_id, resumed=False)
42
+
43
+ async def resume_task(self, task_id: str) -> None:
44
+ """Schedule the flow of a task whose step an engine restart interrupted."""
45
+ self._start(task_id, resumed=True)
46
+
47
+ async def ensure_flows(self) -> None:
48
+ """Start a resumed flow for each unheld running or integrating task without one."""
49
+ for task in self._tasks.list_active():
50
+ if task.status in _IN_FLIGHT and not task.held:
51
+ self._start(task.id, resumed=True)
52
+
53
+ def _start(self, task_id: str, *, resumed: bool) -> None:
54
+ existing = self._flow_tasks.get(task_id)
55
+ if existing is not None and not existing.done():
56
+ return
57
+ self._flow_tasks[task_id] = asyncio.create_task(self._run_flow(task_id, resumed=resumed))
58
+
59
+ async def cancel_task(self, task_id: str) -> None:
60
+ """Interrupt and await one supervised flow coroutine, if present."""
61
+ current = self._flow_tasks.get(task_id)
62
+ if current is None:
63
+ return
64
+ current.cancel()
65
+ with suppress(asyncio.CancelledError):
66
+ await current
67
+ self._flow_tasks.pop(task_id, None)
68
+
69
+ async def supervise(self, logger: Logger) -> None:
70
+ """Log task flow failures and remove completed flows from the active set."""
71
+ for task_id, flow_task in tuple(self._flow_tasks.items()):
72
+ if not flow_task.done():
73
+ continue
74
+ self._flow_tasks.pop(task_id, None)
75
+ if flow_task.cancelled():
76
+ continue
77
+ error = flow_task.exception()
78
+ if error is not None:
79
+ logger.error(
80
+ "task flow %s failed",
81
+ task_id,
82
+ exc_info=(type(error), error, error.__traceback__),
83
+ )
84
+
85
+ async def shutdown(self) -> None:
86
+ """Cancel and await all task flows before the daemon closes its resources."""
87
+ for task in tuple(self._flow_tasks.values()):
88
+ task.cancel()
89
+ if self._flow_tasks:
90
+ await asyncio.gather(*self._flow_tasks.values(), return_exceptions=True)
91
+ self._flow_tasks.clear()
92
+
93
+ async def _run_flow(self, task_id: str, *, resumed: bool) -> None:
94
+ try:
95
+ await self._flow.run(task_id, resumed=resumed)
96
+ finally:
97
+ current = self._tasks.get(task_id)
98
+ if current is not None and (
99
+ current.status is Status.DONE or current.status_reason is StatusReason.SUPERSEDED
100
+ ):
101
+ await self._finisher.finish_task(current)
@@ -0,0 +1,223 @@
1
+ """Operator take over and hand back of one task role's session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from typing import TYPE_CHECKING, NoReturn
7
+
8
+ from shikiru.agents.contracts import Rung
9
+ from shikiru.domain.commands import COMMAND_ID_KEY
10
+ from shikiru.domain.events import EventKind
11
+ from shikiru.domain.ladder import pick_rung
12
+ from shikiru.domain.tasks import Hold, Role, Status, Task
13
+ from shikiru.engine.turns import session_environment
14
+ from shikiru.errors import PROCESS_FAILURES, CommandRejectedError
15
+ from shikiru.paths import run_dir, task_root
16
+ from shikiru.tmux.client import FORMAT_PANE_PID
17
+ from shikiru.workspace.messages import command_message_key
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Awaitable, Callable
21
+ from contextlib import AbstractContextManager
22
+ from pathlib import Path
23
+
24
+ from shikiru.config.settings import Settings
25
+ from shikiru.domain.runs import Run
26
+ from shikiru.engine.agents import AgentPool
27
+ from shikiru.engine.layout_guard import LayoutGuard
28
+ from shikiru.store.events import EventLog
29
+ from shikiru.store.runs import RunStore
30
+ from shikiru.store.tasks import TaskStore
31
+ from shikiru.tmux.client import Tmux
32
+ from shikiru.workspace.messages import OperatorMessages
33
+
34
+ HAND_BACK_MESSAGE = (
35
+ "The Operator worked in this session since your last turn; take their changes into account."
36
+ )
37
+ _ACTIVE_STATUSES = (Status.RUNNING, Status.INTEGRATING)
38
+ TAKEOVER_PANE_OPTION = "@shk_takeover"
39
+ _FORMAT_TAKEOVER = f"#{{{TAKEOVER_PANE_OPTION}}}"
40
+
41
+
42
+ class Takeover:
43
+ """Moves a role's session between the engine's headless turns and the Operator's pane."""
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ tmux: Tmux,
49
+ layout: LayoutGuard,
50
+ agents: AgentPool,
51
+ tasks: TaskStore,
52
+ runs: RunStore,
53
+ messages: OperatorMessages,
54
+ events: EventLog,
55
+ project_root: Path,
56
+ project_name: str,
57
+ settings: Settings,
58
+ cancel_task: Callable[[str], Awaitable[None]],
59
+ start_task: Callable[[str], Awaitable[None]],
60
+ refresh_titles: Callable[[Task], Awaitable[None]],
61
+ atomically: Callable[[], AbstractContextManager[object]],
62
+ ) -> None:
63
+ """Drive panes through `tmux`/`layout` and task flows through the two callbacks."""
64
+ self._tmux = tmux
65
+ self._layout = layout
66
+ self._agents = agents
67
+ self._tasks = tasks
68
+ self._runs = runs
69
+ self._messages = messages
70
+ self._events = events
71
+ self._project_root = project_root
72
+ self._project_name = project_name
73
+ self._settings = settings
74
+ self._cancel_task = cancel_task
75
+ self._start_task = start_task
76
+ self._refresh_titles = refresh_titles
77
+ self._atomically = atomically
78
+
79
+ async def take_over(self, task: Task, role: Role, command_id: int) -> None:
80
+ """Stop `task`'s turn, hold it and run `role`'s session interactively in its pane.
81
+
82
+ The run mark, hold and event (naming `command_id`) commit together before the pane is
83
+ respawned; a replay after that commit skips them and only respawns the pane, which is
84
+ repeatable, while the role is still taken over.
85
+ """
86
+ if not self._events.command_applied(EventKind.TAKEN_OVER, command_id):
87
+ if self._runs.get_taken_over(task.id, role) is not None:
88
+ message = f"{task.id} {role.value} is already taken over"
89
+ raise CommandRejectedError(message)
90
+ run = self._runs.get_live(task.id, role)
91
+ if run is None or run.session_id is None:
92
+ message = f"{task.id} has no {role.value} session to take over"
93
+ raise CommandRejectedError(message)
94
+ _role_pane(task, role)
95
+ if task.status in _ACTIVE_STATUSES:
96
+ await self._cancel_task(task.id)
97
+ with self._atomically():
98
+ self._runs.mark_taken_over(run.id)
99
+ self._tasks.set_holds(task.id, self._current(task.id).holds | {Hold.INTERVENTION})
100
+ self._events.append(
101
+ EventKind.TAKEN_OVER,
102
+ task_id=task.id,
103
+ message=f"{task.id} {role.value} taken over",
104
+ data={
105
+ "role": role.value,
106
+ "session_id": run.session_id,
107
+ COMMAND_ID_KEY: command_id,
108
+ },
109
+ )
110
+ owned = self._runs.get_taken_over(task.id, role)
111
+ if owned is None or owned.session_id is None:
112
+ message = f"{task.id} {role.value} take over did not hold"
113
+ raise CommandRejectedError(message)
114
+ try:
115
+ await self._ensure_interactive(
116
+ self._current(task.id), role, owned, owned.session_id, command_id
117
+ )
118
+ except PROCESS_FAILURES as error:
119
+ await self._release_unstarted(task, role, owned.id, error)
120
+ await self._refresh_titles(self._current(task.id))
121
+
122
+ async def _ensure_interactive(
123
+ self, task: Task, role: Role, run: Run, session_id: str, command_id: int
124
+ ) -> None:
125
+ pane = _role_pane(task, role)
126
+ if await self._interactive_is_live(pane, command_id):
127
+ return
128
+ configured_rung = pick_rung(task, role, self._settings)
129
+ rung = (
130
+ configured_rung
131
+ if configured_rung.agent == run.agent
132
+ else Rung(agent=run.agent, model=run.model, effort=run.effort)
133
+ )
134
+ root = task_root(self._project_root, task.id)
135
+ task_run_dir = run_dir(self._project_root, task.id)
136
+ argv = self._agents.get(run.agent).interactive_command(session_id, task_run_dir, rung)
137
+ env = session_environment(task, role, task_run_dir, self._project_name)
138
+ await self._tmux.respawn_pane(pane, argv, env=env, cwd=root)
139
+ pid = await self._tmux.display(pane, FORMAT_PANE_PID)
140
+ with self._atomically():
141
+ if (run.model, run.effort) != (rung.model, rung.effort):
142
+ self._runs.set_rung(run.id, rung)
143
+ self._runs.set_session(run.id, session_id, shlex.join(argv))
144
+ await self._tmux.set_pane_option(pane, TAKEOVER_PANE_OPTION, _live_marker(command_id, pid))
145
+
146
+ async def _interactive_is_live(self, pane: str, command_id: int) -> bool:
147
+ marker = await self._tmux.display(pane, _FORMAT_TAKEOVER)
148
+ pid = await self._tmux.display(pane, FORMAT_PANE_PID)
149
+ return marker == _live_marker(command_id, pid) and not await self._tmux.is_pane_dead(pane)
150
+
151
+ async def _release_unstarted(
152
+ self, task: Task, role: Role, run_id: int, error: Exception
153
+ ) -> NoReturn:
154
+ with self._atomically():
155
+ self._runs.mark_live(run_id)
156
+ if not self._runs.has_taken_over(task.id):
157
+ holds = self._current(task.id).holds - {Hold.INTERVENTION}
158
+ self._tasks.set_holds(task.id, holds)
159
+ self._events.append(
160
+ EventKind.TAKEOVER_LOST,
161
+ task_id=task.id,
162
+ message=f"{task.id} {role.value} take over released: its pane could not be started",
163
+ data={"role": role.value},
164
+ )
165
+ current = self._current(task.id)
166
+ if current.status in _ACTIVE_STATUSES and not current.held:
167
+ await self._start_task(task.id)
168
+ await self._refresh_titles(current)
169
+ message = (
170
+ f"{task.id} {role.value} was not taken over: its pane could not be started ({error}); "
171
+ "nothing is held and the task continues, take over again to retry"
172
+ )
173
+ raise CommandRejectedError(message) from error
174
+
175
+ async def hand_back(self, task: Task, role: Role, command_id: int) -> None:
176
+ """Return `role`'s taken-over session to the engine and re-run the current step.
177
+
178
+ The run, hold and event changes commit together, and the event names `command_id`; a
179
+ replay after that commit skips them and only repeats the follow-up.
180
+ """
181
+ if not self._events.command_applied(EventKind.HANDED_BACK, command_id):
182
+ run = self._runs.get_taken_over(task.id, role)
183
+ if run is None:
184
+ message = f"{task.id} {role.value} is not taken over"
185
+ raise CommandRejectedError(message)
186
+ await self._layout.start_view(task.id, role, _role_pane(task, role))
187
+ path = self._messages.queue(
188
+ task.id, role, HAND_BACK_MESSAGE, command_message_key(command_id)
189
+ )
190
+ with self._atomically():
191
+ self._runs.mark_live(run.id)
192
+ if not self._runs.has_taken_over(task.id):
193
+ holds = self._current(task.id).holds - {Hold.INTERVENTION}
194
+ self._tasks.set_holds(task.id, holds)
195
+ self._events.append(
196
+ EventKind.HANDED_BACK,
197
+ task_id=task.id,
198
+ message=f"{task.id} {role.value} handed back",
199
+ data={"role": role.value, "path": str(path), COMMAND_ID_KEY: command_id},
200
+ )
201
+ current = self._current(task.id)
202
+ if current.status in _ACTIVE_STATUSES and not current.held:
203
+ await self._start_task(task.id)
204
+ await self._refresh_titles(self._current(task.id))
205
+
206
+ def _current(self, task_id: str) -> Task:
207
+ task = self._tasks.get(task_id)
208
+ if task is None:
209
+ message = f"task {task_id} disappeared during take over"
210
+ raise CommandRejectedError(message)
211
+ return task
212
+
213
+
214
+ def _live_marker(command_id: int, pid: str) -> str:
215
+ return f"{command_id}:{pid}"
216
+
217
+
218
+ def _role_pane(task: Task, role: Role) -> str:
219
+ pane_id = task.panes.get(role)
220
+ if pane_id is None:
221
+ message = f"{task.id} has no {role.value} pane"
222
+ raise CommandRejectedError(message)
223
+ return pane_id
@@ -0,0 +1,46 @@
1
+ """Git facts of a task's worktree, shared by prompt, evidence and acceptance code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.errors import InvariantError
8
+ from shikiru.paths import task_root
9
+ from shikiru.workspace.facts import render_facts
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from shikiru.config.settings import Settings
15
+ from shikiru.domain.tasks import RepoState, Task
16
+ from shikiru.workspace.facts import GitFacts, WorkspaceFacts
17
+
18
+
19
+ def require_repo(task: Task) -> RepoState:
20
+ """Return the task's repo state or raise when its task root does not exist yet."""
21
+ if task.repo is None:
22
+ message = f"{task.id} has no task root yet"
23
+ raise InvariantError(message)
24
+ return task.repo
25
+
26
+
27
+ class TaskFacts:
28
+ """Collects and renders the facts of one task's worktree."""
29
+
30
+ def __init__(self, *, git_facts: GitFacts, project_root: Path, settings: Settings) -> None:
31
+ """Collect facts from `git_facts` for worktrees under `project_root`."""
32
+ self._git_facts = git_facts
33
+ self._project_root = project_root
34
+ self._settings = settings
35
+
36
+ async def collect(self, task: Task) -> WorkspaceFacts:
37
+ """Collect the raw facts of `task`'s worktree."""
38
+ return await self._git_facts.collect(
39
+ task_root(self._project_root, task.id),
40
+ require_repo(task),
41
+ self._settings.project.trackers,
42
+ )
43
+
44
+ async def text(self, task: Task) -> str:
45
+ """Render the facts of `task`'s worktree as prompt markdown."""
46
+ return render_facts(await self.collect(task))
@@ -0,0 +1,95 @@
1
+ """The fault barrier around one engine tick or command pass."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.errors import ShikiruError
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Awaitable, Callable
12
+ from datetime import datetime
13
+ from logging import Logger
14
+
15
+ from shikiru.clock import Clock
16
+ from shikiru.config.settings import FaultsConfig
17
+ from shikiru.engine.fault_reporter import FaultReporter
18
+
19
+ _BACKOFF_FACTOR = 2
20
+
21
+
22
+ class EngineFaultLimitError(ShikiruError):
23
+ """Unexpected (programming) faults repeated on every tick up to the configured limit."""
24
+
25
+
26
+ class TickGuard:
27
+ """Runs a tick, retrying it with bounded backoff after a fault instead of ending the engine.
28
+
29
+ Recoverable faults (`fault_reporter.RECOVERABLE_FAULTS`) are retried without limit. Any other
30
+ exception is a programming error: it is recorded like the rest, and `unexpected_fault_limit`
31
+ of them in a row end the engine with `EngineFaultLimitError`.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ reporter: FaultReporter,
38
+ clock: Clock,
39
+ settings: FaultsConfig,
40
+ logger: Logger,
41
+ scope: str,
42
+ ) -> None:
43
+ """Record faults through `reporter` under `scope` and pace retries with `clock`."""
44
+ self._reporter = reporter
45
+ self._clock = clock
46
+ self._settings = settings
47
+ self._logger = logger
48
+ self._scope = scope
49
+ self._streak = 0
50
+ self._unexpected = 0
51
+ self._backoff = 0.0
52
+ self._retry_at: datetime | None = None
53
+
54
+ async def attempt[T](self, tick: Callable[[], Awaitable[T]]) -> T | None:
55
+ """Run `tick` unless a backoff is pending, absorbing its fault.
56
+
57
+ Returns what `tick` returned, or `None` when it was skipped or failed.
58
+ """
59
+ if self._retry_at is not None and self._clock.now() < self._retry_at:
60
+ return None
61
+ try:
62
+ result = await tick()
63
+ except Exception as error:
64
+ self._logger.exception("%s failed", self._scope)
65
+ self._record(error)
66
+ return None
67
+ self._recover()
68
+ return result
69
+
70
+ def _record(self, error: Exception) -> None:
71
+ recoverable = self._reporter.report(error, scope=self._scope)
72
+ self._streak += 1
73
+ self._unexpected = 0 if recoverable else self._unexpected + 1
74
+ grown = (
75
+ self._settings.backoff_initial_seconds
76
+ if self._streak == 1
77
+ else self._backoff * _BACKOFF_FACTOR
78
+ )
79
+ self._backoff = min(grown, self._settings.backoff_max_seconds)
80
+ self._retry_at = self._clock.now() + timedelta(seconds=self._backoff)
81
+ self._logger.warning(
82
+ "%s attempt %d failed; retry in %.0fs", self._scope, self._streak, self._backoff
83
+ )
84
+ if self._unexpected >= self._settings.unexpected_fault_limit:
85
+ kind = type(error).__name__
86
+ message = f"{self._unexpected} consecutive unexpected faults, last {kind}: {error}"
87
+ raise EngineFaultLimitError(message) from error
88
+
89
+ def _recover(self) -> None:
90
+ self._reporter.resolve(self._scope)
91
+ if self._streak:
92
+ self._logger.info("%s recovered after %d failed attempts", self._scope, self._streak)
93
+ self._streak = 0
94
+ self._unexpected = 0
95
+ self._retry_at = None