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,90 @@
1
+ """Acceptance of a completed turn's report: a dirty worktree cannot end a task's work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.reports import (
8
+ DECISION_ACCEPT,
9
+ DECISION_CHANGES_REQUESTED,
10
+ DECISION_SUPERSEDE,
11
+ REPORT_JSON_INDENT,
12
+ SEVERITY_BLOCKER,
13
+ FeedbackVerdict,
14
+ Issue,
15
+ Verdict,
16
+ )
17
+ from shikiru.engine.events import emit_turn_completed, emit_verdict
18
+ from shikiru.engine.history import ENGINE_VERDICT_FILENAME
19
+ from shikiru.engine.task_facts import require_repo
20
+ from shikiru.private import write_private_text
21
+ from shikiru.workspace.facts import describe_off_branch
22
+
23
+ if TYPE_CHECKING:
24
+ from shikiru.domain.steps import Outcome
25
+ from shikiru.domain.tasks import Task
26
+ from shikiru.engine.task_facts import TaskFacts
27
+ from shikiru.engine.turn_execution import TurnExecution
28
+ from shikiru.store.events import EventLog
29
+ from shikiru.workspace.facts import WorkspaceFacts
30
+
31
+ _WORK_ENDING_DECISIONS = frozenset({DECISION_ACCEPT, DECISION_SUPERSEDE})
32
+ REJECTION_SUMMARY = "The workspace cannot be integrated as it is."
33
+ UNCOMMITTED_ISSUE = (
34
+ "Uncommitted changes in the workspace: {paths}. "
35
+ "Commit what belongs to this task and discard the rest."
36
+ )
37
+
38
+
39
+ def _blocker(task: Task, facts: WorkspaceFacts) -> str | None:
40
+ branch = require_repo(task).branch
41
+ if facts.head_branch != branch:
42
+ return describe_off_branch(branch, facts.head_branch, facts.commits)
43
+ if facts.status:
44
+ paths = ", ".join(line.strip() for line in facts.status)
45
+ return UNCOMMITTED_ISSUE.format(paths=paths)
46
+ return None
47
+
48
+
49
+ class AcceptanceGuard:
50
+ """Records a completed turn and rejects an accepting verdict over a dirty worktree."""
51
+
52
+ def __init__(self, *, events: EventLog, facts: TaskFacts) -> None:
53
+ """Record through `events` and inspect the worktree through `facts`."""
54
+ self._events = events
55
+ self._facts = facts
56
+
57
+ async def settle[R: Outcome](
58
+ self, task: Task, report: R, execution: TurnExecution
59
+ ) -> R | Verdict:
60
+ """Record the completed turn; return `report`, or the engine's rejection of a dirty accept.
61
+
62
+ An `accept` or `supersede` verdict ends the task's work, so uncommitted changes in its
63
+ worktree replace it with a `changes_requested` verdict written beside the turn's report.
64
+ """
65
+ emit_turn_completed(self._events, task, report, execution)
66
+ if not isinstance(report, Verdict) or report.decision not in _WORK_ENDING_DECISIONS:
67
+ return report
68
+ facts = await self._facts.collect(task)
69
+ description = _blocker(task, facts)
70
+ if description is None:
71
+ return report
72
+ rejection_type = FeedbackVerdict if isinstance(report, FeedbackVerdict) else Verdict
73
+ rejection = rejection_type(
74
+ decision=DECISION_CHANGES_REQUESTED,
75
+ summary=REJECTION_SUMMARY,
76
+ issues=[Issue(severity=SEVERITY_BLOCKER, description=description)],
77
+ )
78
+ write_private_text(
79
+ execution.turn_dir / ENGINE_VERDICT_FILENAME,
80
+ rejection.model_dump_json(indent=REPORT_JSON_INDENT),
81
+ )
82
+ emit_verdict(
83
+ self._events,
84
+ task,
85
+ rejection,
86
+ rejection.summary,
87
+ turn=execution.turn_dir.name,
88
+ role=execution.role,
89
+ )
90
+ return rejection
@@ -0,0 +1,61 @@
1
+ """`AgentPool`: builds and caches one headless `Agent` per name.
2
+
3
+ Builds through an injected `create` callable rather than an `AgentRegistry` directly, keeping
4
+ the engine on the shared `agents.contracts` boundary: callers pass `AgentRegistry.create` bound,
5
+ but the engine itself never imports `agents.registry`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from shikiru.agents.contracts import AgentContext, BillingDeclaration, billing_declaration_for
13
+ from shikiru.errors import ConfigError
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
18
+ from shikiru.agents.contracts import Agent
19
+ from shikiru.clock import Clock
20
+ from shikiru.config.settings import Settings
21
+ from shikiru.process import SubprocessRunner
22
+
23
+
24
+ class AgentPool:
25
+ """Resolves a rung's agent name to its `Agent`, built once and cached."""
26
+
27
+ def __init__(
28
+ self,
29
+ create: Callable[[str, AgentContext], Agent],
30
+ runner: SubprocessRunner,
31
+ settings: Settings,
32
+ clock: Clock,
33
+ ) -> None:
34
+ """Build agents by calling `create(name, context)`, timed by `clock`."""
35
+ self._create = create
36
+ self._runner = runner
37
+ self._settings = settings
38
+ self._clock = clock
39
+ self._agents: dict[str, Agent] = {}
40
+
41
+ def get(self, name: str) -> Agent:
42
+ """Return `name`'s headless `Agent`, building and caching it on first use.
43
+
44
+ Raises `ConfigError` if `name` has no `[agents.<name>]` table: a rung naming an agent
45
+ the project never configured is a configuration mistake, not a default to paper over.
46
+ """
47
+ cached = self._agents.get(name)
48
+ if cached is not None:
49
+ return cached
50
+ config = self._settings.agents.get(name)
51
+ if config is None:
52
+ message = f"agent {name!r} is used by a rung but has no [agents.{name}] table"
53
+ raise ConfigError(message)
54
+ context = AgentContext(options=config.options, runner=self._runner, clock=self._clock)
55
+ agent = self._create(name, context)
56
+ self._agents[name] = agent
57
+ return agent
58
+
59
+ def billing_declaration(self, name: str) -> BillingDeclaration:
60
+ """Return the configured plugin's optional declaration through the contract boundary."""
61
+ return billing_declaration_for(self.get(name))
@@ -0,0 +1,99 @@
1
+ """Decorations that never fail the engine path that sets them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.errors import PROCESS_FAILURES
9
+ from shikiru.tmux.decorator import PaneDecorator
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Awaitable, Mapping
13
+
14
+ from shikiru.tmux.client import Tmux
15
+
16
+ COSMETIC_LOGGER_NAME = __name__
17
+ _LOGGER = logging.getLogger(COSMETIC_LOGGER_NAME)
18
+
19
+
20
+ class CosmeticDecorator(PaneDecorator):
21
+ """A `PaneDecorator` whose failing tmux calls, timeouts included, are logged instead of raised.
22
+
23
+ A streak of failures of one operation is logged once, at its first failure, and ends with
24
+ that operation's next success.
25
+
26
+ A lost pane or server is repaired by the layout guard and must not end a task's flow or a
27
+ command.
28
+
29
+ A title template that cannot be rendered still raises `ConfigError`.
30
+ """
31
+
32
+ def __init__(self, tmux: Tmux) -> None:
33
+ """Decorate through `tmux`."""
34
+ super().__init__(tmux)
35
+ self._failing: set[str] = set()
36
+
37
+ async def _cosmetic(self, name: str, operation: Awaitable[None]) -> None:
38
+ try:
39
+ await operation
40
+ except PROCESS_FAILURES:
41
+ if name not in self._failing:
42
+ _LOGGER.exception("tmux %s failed", name)
43
+ self._failing.add(name)
44
+ else:
45
+ self._failing.discard(name)
46
+
47
+ async def configure_formats(self, window_target: str) -> None:
48
+ """Set the window formats, logging a tmux failure."""
49
+ await self._cosmetic("configure_formats", super().configure_formats(window_target))
50
+
51
+ async def set_pane_state(self, pane_target: str, *, color: str, glyph: str) -> None:
52
+ """Set the pane state, logging a tmux failure."""
53
+ await self._cosmetic(
54
+ "set_pane_state", super().set_pane_state(pane_target, color=color, glyph=glyph)
55
+ )
56
+
57
+ async def refresh_window(
58
+ self,
59
+ window_target: str,
60
+ template: str,
61
+ fields: Mapping[str, object],
62
+ *,
63
+ color: str,
64
+ glyph: str,
65
+ ) -> None:
66
+ """Set the window state and title, logging a tmux failure."""
67
+ await self._cosmetic(
68
+ "refresh_window",
69
+ super().refresh_window(window_target, template, fields, color=color, glyph=glyph),
70
+ )
71
+
72
+ async def set_pane_title(
73
+ self, pane_target: str, template: str, fields: Mapping[str, object]
74
+ ) -> None:
75
+ """Set the pane title, logging a tmux failure."""
76
+ await self._cosmetic(
77
+ "set_pane_title", super().set_pane_title(pane_target, template, fields)
78
+ )
79
+
80
+ async def set_pane_title_styling(
81
+ self,
82
+ pane_target: str,
83
+ template: str,
84
+ fields: Mapping[str, object],
85
+ *,
86
+ field_colors: Mapping[str, str],
87
+ field_segments: Mapping[str, tuple[tuple[str, str], ...]],
88
+ ) -> None:
89
+ """Set pane-title component colors, logging a tmux failure."""
90
+ await self._cosmetic(
91
+ "set_pane_title_styling",
92
+ super().set_pane_title_styling(
93
+ pane_target,
94
+ template,
95
+ fields,
96
+ field_colors=field_colors,
97
+ field_segments=field_segments,
98
+ ),
99
+ )
@@ -0,0 +1,409 @@
1
+ """The asyncio engine daemon: liveness, command dispatch and task supervision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import math
7
+ import os
8
+ from datetime import timedelta
9
+ from typing import TYPE_CHECKING, Self
10
+
11
+ import psutil
12
+
13
+ from shikiru.domain.commands import COMMAND_POLL_SECONDS, Stop
14
+ from shikiru.domain.engine_uptime import (
15
+ ENGINE_PID_FIELD,
16
+ ENGINE_PROCESS_STARTED_AT_FIELD,
17
+ )
18
+ from shikiru.domain.events import STOP_REASON_KEY, EventKind
19
+ from shikiru.errors import ShikiruError
20
+ from shikiru.private import create_private_file, make_private_dir
21
+ from shikiru.process import ProcessIdentity, identify_pid
22
+ from shikiru.store.database import OperationalDatabaseError
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Awaitable, Callable
26
+ from logging import FileHandler, Logger
27
+ from pathlib import Path
28
+ from typing import TextIO
29
+
30
+ from shikiru.clock import Clock
31
+ from shikiru.config.settings import Settings
32
+ from shikiru.domain.events import Event
33
+ from shikiru.engine.handlers import HandlerRegistry
34
+ from shikiru.engine.layout_guard import LayoutGuard
35
+ from shikiru.engine.notification_delivery import NotificationDelivery
36
+ from shikiru.engine.notification_feed import NotificationFeed
37
+ from shikiru.engine.pull_request_closing import PullRequestClosing
38
+ from shikiru.engine.push_service import PushService
39
+ from shikiru.engine.reconcile import Reconciler
40
+ from shikiru.engine.retention import Retention
41
+ from shikiru.engine.scheduler import Scheduler
42
+ from shikiru.engine.session_guard import SessionGuard
43
+ from shikiru.engine.supervisor import TaskSupervisor
44
+ from shikiru.engine.tick_guard import TickGuard
45
+ from shikiru.store.commands import CommandQueue
46
+ from shikiru.store.events import EventLog
47
+ from shikiru.store.tasks import TaskStore
48
+
49
+ ENGINE_LOGGER_NAME = "shikiru"
50
+ _ENGINE_LOCK_METADATA_PARTS = 2
51
+ ENGINE_LOCK_PROBE_GRACE_SECONDS = 1.0
52
+ ENGINE_LOCK_RETRY_SECONDS = 0.02
53
+
54
+
55
+ class EngineAlreadyRunningError(ShikiruError):
56
+ """The project already has an engine holding its exclusive lock."""
57
+
58
+
59
+ class EngineLock:
60
+ """An exclusive process lock held for exactly one engine lifetime."""
61
+
62
+ def __init__(self, path: Path) -> None:
63
+ self._path = path
64
+ self._file: TextIO | None = None
65
+ self._identity: ProcessIdentity | None = None
66
+
67
+ def acquire(self) -> None:
68
+ """Acquire the lock without blocking, or raise `EngineAlreadyRunningError`."""
69
+ make_private_dir(self._path.parent)
70
+ create_private_file(self._path)
71
+ self._file = self._path.open("a+")
72
+ try:
73
+ fcntl.flock(self._file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
74
+ except BlockingIOError:
75
+ self._file.close()
76
+ self._file = None
77
+ message = f"engine lock is held: {self._path}"
78
+ raise EngineAlreadyRunningError(message) from None
79
+ self._file.seek(0)
80
+ self._file.truncate()
81
+ identity = ProcessIdentity(os.getpid(), psutil.Process(os.getpid()).create_time())
82
+ self._identity = identity
83
+ self._file.write(f"{identity.pid} {identity.started_at!r}")
84
+ self._file.flush()
85
+
86
+ @property
87
+ def identity(self) -> ProcessIdentity:
88
+ """Return this lock holder's recorded process identity."""
89
+ identity = self._identity
90
+ if identity is None:
91
+ message = "engine lock identity is unavailable before acquisition"
92
+ raise RuntimeError(message)
93
+ return identity
94
+
95
+ @staticmethod
96
+ def identity_if_running(path: Path) -> ProcessIdentity | None:
97
+ """Return the verified identity written by the process currently holding `path`."""
98
+ try:
99
+ lock_file = path.open()
100
+ except FileNotFoundError:
101
+ return None
102
+ with lock_file:
103
+ try:
104
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
105
+ except BlockingIOError:
106
+ lock_file.seek(0)
107
+ contents = lock_file.read().split()
108
+ if len(contents) != _ENGINE_LOCK_METADATA_PARTS:
109
+ return None
110
+ try:
111
+ pid = int(contents[0])
112
+ process_started_at = float(contents[1])
113
+ except ValueError:
114
+ return None
115
+ if pid < 1 or not math.isfinite(process_started_at):
116
+ return None
117
+ identity = ProcessIdentity(pid, process_started_at)
118
+ return identity if identity.matches(identify_pid(identity.pid)) else None
119
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
120
+ return None
121
+
122
+ @staticmethod
123
+ def pid_if_running(path: Path) -> int | None:
124
+ """Return the verified PID written by the process that currently holds `path`."""
125
+ identity = EngineLock.identity_if_running(path)
126
+ return identity.pid if identity is not None else None
127
+
128
+ def release(self) -> None:
129
+ """Release the lock and close its file descriptor."""
130
+ if self._file is None:
131
+ return
132
+ fcntl.flock(self._file.fileno(), fcntl.LOCK_UN)
133
+ self._file.close()
134
+ self._file = None
135
+ self._identity = None
136
+
137
+ def __enter__(self) -> Self:
138
+ self.acquire()
139
+ return self
140
+
141
+ def __exit__(self, _type: object, _value: object, _traceback: object) -> None:
142
+ self.release()
143
+
144
+
145
+ def engine_is_running(lock_path: Path) -> bool:
146
+ """Return whether another process currently holds `lock_path`."""
147
+ try:
148
+ lock_file = lock_path.open("r")
149
+ except FileNotFoundError:
150
+ return False
151
+ try:
152
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
153
+ except BlockingIOError:
154
+ return True
155
+ else:
156
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
157
+ return False
158
+ finally:
159
+ lock_file.close()
160
+
161
+
162
+ class Engine:
163
+ """Poll commands, run the scheduler and supervise one coroutine per active task."""
164
+
165
+ def __init__(
166
+ self,
167
+ *,
168
+ lock_path: Path,
169
+ command_queue: CommandQueue,
170
+ tasks: TaskStore,
171
+ events: EventLog,
172
+ scheduler: Scheduler,
173
+ task_supervisor: TaskSupervisor,
174
+ handlers: HandlerRegistry,
175
+ layout: LayoutGuard,
176
+ session_guard: SessionGuard,
177
+ tick_guard: TickGuard,
178
+ command_guard: TickGuard,
179
+ loop_guard: TickGuard,
180
+ reconciler: Reconciler,
181
+ notifications: NotificationDelivery,
182
+ notification_feed: NotificationFeed,
183
+ retention: Retention,
184
+ retention_guard: TickGuard,
185
+ pull_request_closing: PullRequestClosing,
186
+ push_service: PushService | None,
187
+ settings: Settings,
188
+ clock: Clock,
189
+ close_state: Callable[[], None],
190
+ logger: Logger,
191
+ log_handler: FileHandler,
192
+ ) -> None:
193
+ """Wire the daemon loop to its stores and bootstrap-built command registry."""
194
+ self._lock = EngineLock(lock_path)
195
+ self._commands = command_queue
196
+ self._tasks = tasks
197
+ self._events = events
198
+ self._scheduler = scheduler
199
+ self._task_supervisor = task_supervisor
200
+ self._handlers = handlers
201
+ self._layout = layout
202
+ self._session_guard = session_guard
203
+ self._tick_guard = tick_guard
204
+ self._command_guard = command_guard
205
+ self._loop_guard = loop_guard
206
+ self._reconciled = False
207
+ self._stop_reason: str | None = None
208
+ self._stopped_event: Event | None = None
209
+ self._reconciler = reconciler
210
+ self._notifications = notifications
211
+ self._notification_feed = notification_feed
212
+ self._retention = retention
213
+ self._retention_guard = retention_guard
214
+ self._pull_request_closing = pull_request_closing
215
+ self._push_service = push_service
216
+ self._settings = settings
217
+ self._clock = clock
218
+ self._close_state = close_state
219
+ self._last_scheduled_event = 0
220
+ self._logger = logger
221
+ self._log_handler = log_handler
222
+
223
+ async def _acquire_lock(self) -> None:
224
+ deadline = self._clock.now() + timedelta(seconds=ENGINE_LOCK_PROBE_GRACE_SECONDS)
225
+ while True:
226
+ try:
227
+ self._lock.acquire()
228
+ except EngineAlreadyRunningError:
229
+ if self._clock.now() >= deadline:
230
+ raise
231
+ await self._clock.sleep(ENGINE_LOCK_RETRY_SECONDS)
232
+ else:
233
+ return
234
+
235
+ async def run(self) -> None:
236
+ """Hold liveness, process commands and schedule tasks until `Stop` or cancellation."""
237
+ try:
238
+ await self._acquire_lock()
239
+ except BaseException:
240
+ self._close_logging()
241
+ self._close_state()
242
+ raise
243
+ try:
244
+ await self._until_done(self._restore_notifications)
245
+ self._notifications.start()
246
+ self._pull_request_closing.start()
247
+ await self._until_done(self._record_start)
248
+ next_tick = self._clock.now()
249
+ while not self._task_supervisor.stop_requested:
250
+ await self._task_supervisor.supervise(self._logger)
251
+ self._notifications.supervise(self._logger)
252
+ self._pull_request_closing.supervise(self._logger)
253
+ if not self._reconciled:
254
+ await self._tick_guard.attempt(self._prepare)
255
+ commands_changed = bool(await self._command_guard.attempt(self._process_commands))
256
+ if self._task_supervisor.stop_requested:
257
+ break
258
+ now = self._clock.now()
259
+ events_changed = await self._loop_guard.attempt(self._new_events) or []
260
+ if events_changed:
261
+ self._last_scheduled_event = max(event.id for event in events_changed)
262
+ if commands_changed or events_changed or now >= next_tick:
263
+ await self._tick_guard.attempt(self._tick)
264
+ next_tick = now + timedelta(seconds=self._settings.orchestration.poll_seconds)
265
+ if self._reconciled and self._push_service is not None:
266
+ self._push_service.poll(self._logger)
267
+ if self._reconciled:
268
+ await self._retention_guard.attempt(self._retention.run_if_due)
269
+ await self._loop_guard.attempt(self._scan_notifications)
270
+ await self._clock.sleep(COMMAND_POLL_SECONDS)
271
+ await self._task_supervisor.supervise(self._logger)
272
+ except Exception as error:
273
+ self._stop_reason = f"{type(error).__name__}: {error}"
274
+ self._logger.exception("engine stopping on an unrecoverable fault")
275
+ raise
276
+ finally:
277
+ await self._shutdown()
278
+
279
+ def request_stop(self) -> None:
280
+ """Ask the command loop to stop after acknowledging the current command."""
281
+ self._task_supervisor.request_stop()
282
+
283
+ async def _until_done(self, step: Callable[[], Awaitable[bool]]) -> None:
284
+ while await self._loop_guard.attempt(step) is None:
285
+ if self._task_supervisor.stop_requested:
286
+ return
287
+ await self._clock.sleep(COMMAND_POLL_SECONDS)
288
+
289
+ async def _restore_notifications(self) -> bool:
290
+ self._notification_feed.restore()
291
+ return True
292
+
293
+ async def _record_start(self) -> bool:
294
+ self._events.append(
295
+ EventKind.ENGINE_STARTED,
296
+ message="engine started",
297
+ data=_engine_identity_data(self._lock.identity),
298
+ )
299
+ return True
300
+
301
+ async def _new_events(self) -> list[Event]:
302
+ return self._events.since(self._last_scheduled_event)
303
+
304
+ async def _scan_notifications(self) -> None:
305
+ self._notification_feed.scan()
306
+
307
+ async def _prepare(self) -> None:
308
+ await self._session_guard.ensure()
309
+ if not self._reconciled:
310
+ await self._reconciler.reconcile()
311
+ self._reconciled = True
312
+
313
+ async def _tick(self) -> None:
314
+ await self._prepare()
315
+ await self._layout.restore(self._tasks.list_active())
316
+ schedule = await self._scheduler.run_once()
317
+ for dispatch in schedule.dispatches:
318
+ await self._task_supervisor.start_task(dispatch.task_id)
319
+ await self._task_supervisor.ensure_flows()
320
+ await self._reconciler.restore_escalations()
321
+ scheduled_events = self._events.since(self._last_scheduled_event)
322
+ if scheduled_events:
323
+ self._last_scheduled_event = max(event.id for event in scheduled_events)
324
+
325
+ async def _process_commands(self) -> bool:
326
+ changed = False
327
+ for queued in self._commands.pending():
328
+ if not self._reconciled and not isinstance(queued.command, Stop):
329
+ continue
330
+ try:
331
+ result = await self._handlers.handle(queued.command)
332
+ except ShikiruError as error:
333
+ self._commands.mark_rejected(queued.id, {"error": str(error)})
334
+ continue
335
+ except OperationalDatabaseError:
336
+ raise
337
+ except Exception as error:
338
+ self._commands.mark_rejected(queued.id, {"error": str(error)})
339
+ raise
340
+ self._commands.mark_done(queued.id, result)
341
+ changed = True
342
+ return changed
343
+
344
+ async def _shutdown(self) -> None:
345
+ self._stopped_event = None
346
+ failures: list[Exception] = []
347
+ try:
348
+ for step in (
349
+ self._record_stop,
350
+ self._notifications.shutdown,
351
+ self._persist_notification_cursor,
352
+ self._pull_request_closing.shutdown,
353
+ self._shutdown_pushes,
354
+ self._deliver_stop,
355
+ self._scheduler.shutdown,
356
+ self._task_supervisor.shutdown,
357
+ ):
358
+ try:
359
+ await step()
360
+ except Exception as error:
361
+ self._logger.exception("engine shutdown step failed")
362
+ failures.append(error)
363
+ finally:
364
+ try:
365
+ self._lock.release()
366
+ finally:
367
+ self._close_logging()
368
+ self._close_state()
369
+ if failures:
370
+ raise failures[0]
371
+
372
+ async def _record_stop(self) -> None:
373
+ reason = self._stop_reason
374
+ self._stopped_event = self._events.append(
375
+ EventKind.ENGINE_STOPPED,
376
+ message="engine stopped" if reason is None else f"engine stopped: {reason}",
377
+ data={
378
+ **_engine_identity_data(self._lock.identity),
379
+ **({} if reason is None else {STOP_REASON_KEY: reason}),
380
+ },
381
+ )
382
+
383
+ async def _persist_notification_cursor(self) -> None:
384
+ self._notification_feed.persist()
385
+
386
+ async def _shutdown_pushes(self) -> None:
387
+ if self._push_service is not None:
388
+ await self._push_service.shutdown()
389
+
390
+ async def _deliver_stop(self) -> None:
391
+ if self._stop_reason is not None and self._stopped_event is not None:
392
+ await self._notifications.deliver_final(
393
+ self._stopped_event, self._settings.orchestration.faults.stop_notify_seconds
394
+ )
395
+
396
+ def _close_logging(self) -> None:
397
+ handler = self._log_handler
398
+ self._logger.removeHandler(handler)
399
+ handler.close()
400
+
401
+
402
+ def _engine_identity_data(identity: ProcessIdentity) -> dict[str, int | float]:
403
+ if identity.started_at is None:
404
+ message = "engine process start time is unavailable"
405
+ raise RuntimeError(message)
406
+ return {
407
+ ENGINE_PID_FIELD: identity.pid,
408
+ ENGINE_PROCESS_STARTED_AT_FIELD: identity.started_at,
409
+ }
@@ -0,0 +1,37 @@
1
+ """Dependency-cancellation escalation orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.steps import Transition
8
+ from shikiru.domain.tasks import EscalationReason
9
+
10
+ if TYPE_CHECKING:
11
+ from shikiru.domain.tasks import Task
12
+ from shikiru.engine.escalations import EscalationRaiser
13
+ from shikiru.engine.transitions import TransitionWriter
14
+
15
+
16
+ class DependencyEscalator:
17
+ """Apply and packet a dependency-cancelled escalation through one boundary."""
18
+
19
+ def __init__(self, writer: TransitionWriter, raiser: EscalationRaiser) -> None:
20
+ """Use `writer` for the state transition and `raiser` for the operator packet."""
21
+ self._writer = writer
22
+ self._raiser = raiser
23
+
24
+ async def __call__(self, task: Task) -> None:
25
+ """Escalate `task` after the scheduler finds a cancelled dependency."""
26
+ transition = Transition.escalate_current(task, EscalationReason.DEPENDENCY_CANCELLED)
27
+ await self._raiser.raise_escalation(
28
+ task,
29
+ step=transition.step,
30
+ reason=EscalationReason.DEPENDENCY_CANCELLED,
31
+ question="A dependency was cancelled; choose whether to drop it or cancel this task.",
32
+ attempted="The scheduler found a cancelled dependency before dispatch.",
33
+ reports=(),
34
+ facts="",
35
+ view_tail="",
36
+ apply_state=lambda: self._writer.persist(task, transition, None),
37
+ )