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,60 @@
1
+ """Shared terminal task cleanup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.tasks import Status
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ from shikiru.domain.tasks import Task
13
+ from shikiru.engine.feedback_audit import FeedbackWorkAudit
14
+ from shikiru.engine.layout_guard import LayoutGuard
15
+ from shikiru.engine.pull_request_closing import PullRequestClosing
16
+ from shikiru.engine.sessions import SessionManager
17
+ from shikiru.workspace.roots import TaskRootBuilder
18
+
19
+
20
+ class TaskFinisher:
21
+ """Close task sessions and window, queue a cancelled task's PR close, remove its worktree."""
22
+
23
+ def __init__(
24
+ self,
25
+ sessions: SessionManager,
26
+ layout: LayoutGuard,
27
+ roots: TaskRootBuilder,
28
+ pull_requests: PullRequestClosing,
29
+ audit: FeedbackWorkAudit,
30
+ ) -> None:
31
+ self._sessions = sessions
32
+ self._layout = layout
33
+ self._roots = roots
34
+ self._pull_requests = pull_requests
35
+ self._audit = audit
36
+ self._finished_tasks: set[str] = set()
37
+
38
+ async def finish_task(self, task: Task) -> Path | None:
39
+ """Release task resources after it reaches a terminal lifecycle state.
40
+
41
+ A cancelled task's open pull request is queued for closing off the calling loop, never
42
+ awaited here. The worktree goes last, so a task root left behind marks unfinished
43
+ cleanup.
44
+ A done task whose branch holds feedback commits the pull request never received keeps
45
+ its worktree and branch; the merged evidence still completes it.
46
+ Returns the task root when unintegrated work kept it in place.
47
+ """
48
+ if task.id in self._finished_tasks:
49
+ return None
50
+ self._sessions.close_task(task.id)
51
+ await self._layout.close(task)
52
+ if task.status is Status.CANCELLED:
53
+ self._pull_requests.request(task.id)
54
+ integrated = task.status is Status.DONE
55
+ keep_work = integrated and await self._audit.holds_unpublished_work(task)
56
+ kept = await self._roots.cleanup(
57
+ task.id, task.repo, integrated=integrated and not keep_work, keep_work=keep_work
58
+ )
59
+ self._finished_tasks.add(task.id)
60
+ return kept
@@ -0,0 +1,317 @@
1
+ """Consumes one headless turn stream and records its facts and live view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from dataclasses import asdict
8
+ from datetime import datetime
9
+ from enum import Enum
10
+ from functools import partial
11
+ from typing import TYPE_CHECKING
12
+
13
+ from shikiru.agents.contracts import (
14
+ AgentSignal,
15
+ Compacted,
16
+ Message,
17
+ QuotaReported,
18
+ SessionStarted,
19
+ ToolFinished,
20
+ ToolStarted,
21
+ Turn,
22
+ TurnEnded,
23
+ TurnFailed,
24
+ UsageReported,
25
+ )
26
+ from shikiru.domain.activity import is_stalled, seconds_until_stall
27
+ from shikiru.domain.events import EventKind
28
+ from shikiru.domain.recovery import ProtocolError, Stalled
29
+ from shikiru.private import append_private_text, make_private_dir
30
+ from shikiru.ui.glyphs import GLYPH_STALLED, ROLE_GLYPHS
31
+ from shikiru.ui.tokens import HOLD_COLOR, ROLE_COLORS
32
+ from shikiru.view_log import (
33
+ VIEW_RUNG_KEY,
34
+ VIEW_SIGNAL_KEY,
35
+ VIEW_TIME_KEY,
36
+ VIEW_TURN_KEY,
37
+ view_log_path,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import AsyncIterator, Awaitable, Callable
42
+ from pathlib import Path
43
+
44
+ from shikiru.agents.contracts import Agent, Rung
45
+ from shikiru.clock import Clock
46
+ from shikiru.config.settings import Settings
47
+ from shikiru.domain.tasks import Role, Task
48
+ from shikiru.engine.quota_reconciler import QuotaReconciler
49
+ from shikiru.engine.sessions import SessionManager
50
+ from shikiru.engine.turn_processes import TurnProcesses
51
+ from shikiru.store.events import EventLog
52
+ from shikiru.store.runs import RunStore
53
+ from shikiru.tmux.decorator import PaneDecorator
54
+
55
+ _LINE_ENDING = "\n"
56
+
57
+
58
+ class TurnMonitor:
59
+ """Read a turn's signals once, persist run facts and stop a stalled process."""
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ sessions: SessionManager,
65
+ runs: RunStore,
66
+ quotas: QuotaReconciler,
67
+ events: EventLog,
68
+ clock: Clock,
69
+ settings: Settings,
70
+ decorator: PaneDecorator,
71
+ refresh_titles: Callable[[Task], Awaitable[None]],
72
+ processes: TurnProcesses,
73
+ ) -> None:
74
+ self._sessions = sessions
75
+ self._runs = runs
76
+ self._quotas = quotas
77
+ self._events = events
78
+ self._clock = clock
79
+ self._settings = settings
80
+ self._decorator = decorator
81
+ self._refresh_titles = refresh_titles
82
+ self._processes = processes
83
+
84
+ def process_recorder(self, run_dir: Path, role: Role) -> Callable[[int], None]:
85
+ """Return the callback recording each process of `role`'s turn as it is spawned."""
86
+ return partial(self._processes.record, run_dir, role)
87
+
88
+ async def run(
89
+ self,
90
+ turn: Turn,
91
+ *,
92
+ agent: Agent,
93
+ task: Task,
94
+ role: Role,
95
+ rung: Rung,
96
+ run_id: int,
97
+ turn_dir: Path,
98
+ run_dir: Path,
99
+ ) -> TurnEnded | TurnFailed | Stalled | ProtocolError:
100
+ """Consume `turn` through its terminal signal, or stop it when it stalls."""
101
+ iterator = turn.signals()
102
+ last_signal_at = self._clock.now()
103
+ tool_running = False
104
+ pending_signal = asyncio.create_task(_next_signal(iterator))
105
+ try:
106
+ await self._decorator.set_pane_state(
107
+ task.panes[role], color=ROLE_COLORS[role], glyph=ROLE_GLYPHS[role]
108
+ )
109
+ while True:
110
+ now = self._clock.now()
111
+ timeouts = self._settings.orchestration.timeouts
112
+ if is_stalled(last_signal_at, tool_running, now, timeouts):
113
+ await turn.stop()
114
+ tool_running, pending_signal = await self._drain_stopped_turn(
115
+ pending_signal,
116
+ iterator,
117
+ turn_dir=turn_dir,
118
+ run_dir=run_dir,
119
+ agent=agent,
120
+ task=task,
121
+ role=role,
122
+ rung=rung,
123
+ run_id=run_id,
124
+ tool_running=tool_running,
125
+ )
126
+ await self._decorator.set_pane_state(
127
+ task.panes[role], color=HOLD_COLOR, glyph=GLYPH_STALLED
128
+ )
129
+ return Stalled()
130
+ poll_timeout = min(
131
+ self._settings.orchestration.poll_seconds,
132
+ seconds_until_stall(last_signal_at, tool_running, now, timeouts),
133
+ )
134
+ signal_ready = await self._wait_for_signal(pending_signal, poll_timeout)
135
+ if not signal_ready:
136
+ continue
137
+ signal = pending_signal.result()
138
+ pending_signal = asyncio.create_task(_next_signal(iterator))
139
+ last_signal_at = self._clock.now()
140
+ self._append_view(run_dir, turn_dir, role, rung, signal)
141
+ tool_running, terminal = await self._record_signal(
142
+ signal,
143
+ agent=agent,
144
+ task=task,
145
+ role=role,
146
+ rung=rung,
147
+ run_id=run_id,
148
+ run_dir=run_dir,
149
+ tool_running=tool_running,
150
+ )
151
+ if terminal is not None:
152
+ return terminal
153
+ except StopAsyncIteration:
154
+ return ProtocolError()
155
+ except asyncio.CancelledError:
156
+ await turn.stop()
157
+ _, pending_signal = await asyncio.shield(
158
+ self._drain_stopped_turn(
159
+ pending_signal,
160
+ iterator,
161
+ turn_dir=turn_dir,
162
+ run_dir=run_dir,
163
+ agent=agent,
164
+ task=task,
165
+ role=role,
166
+ rung=rung,
167
+ run_id=run_id,
168
+ tool_running=tool_running,
169
+ )
170
+ )
171
+ raise
172
+ except BaseException:
173
+ await turn.stop()
174
+ raise
175
+ finally:
176
+ if not pending_signal.done():
177
+ pending_signal.cancel()
178
+ await asyncio.gather(pending_signal, return_exceptions=True)
179
+ self._processes.clear(run_dir, role)
180
+
181
+ async def _drain_stopped_turn(
182
+ self,
183
+ pending_signal: asyncio.Task[AgentSignal],
184
+ iterator: AsyncIterator[AgentSignal],
185
+ *,
186
+ turn_dir: Path,
187
+ run_dir: Path,
188
+ agent: Agent,
189
+ task: Task,
190
+ role: Role,
191
+ rung: Rung,
192
+ run_id: int,
193
+ tool_running: bool,
194
+ ) -> tuple[bool, asyncio.Task[AgentSignal]]:
195
+ try:
196
+ async with asyncio.timeout(self._settings.orchestration.shutdown_seconds):
197
+ while True:
198
+ try:
199
+ signal = await pending_signal
200
+ except StopAsyncIteration:
201
+ return tool_running, pending_signal
202
+ self._append_view(run_dir, turn_dir, role, rung, signal)
203
+ tool_running, terminal = await self._record_signal(
204
+ signal,
205
+ agent=agent,
206
+ task=task,
207
+ role=role,
208
+ rung=rung,
209
+ run_id=run_id,
210
+ run_dir=run_dir,
211
+ tool_running=tool_running,
212
+ )
213
+ if terminal is not None:
214
+ return tool_running, pending_signal
215
+ pending_signal = asyncio.create_task(_next_signal(iterator))
216
+ except TimeoutError:
217
+ if not pending_signal.done():
218
+ pending_signal.cancel()
219
+ await asyncio.gather(pending_signal, return_exceptions=True)
220
+ return tool_running, pending_signal
221
+
222
+ async def _record_signal(
223
+ self,
224
+ signal: AgentSignal,
225
+ *,
226
+ agent: Agent,
227
+ task: Task,
228
+ role: Role,
229
+ rung: Rung,
230
+ run_id: int,
231
+ run_dir: Path,
232
+ tool_running: bool,
233
+ ) -> tuple[bool, TurnEnded | TurnFailed | None]:
234
+ if isinstance(signal, SessionStarted):
235
+ self._sessions.record_session_started(
236
+ run_id=run_id,
237
+ agent=agent,
238
+ session_id=signal.session_id,
239
+ run_dir=run_dir,
240
+ rung=rung,
241
+ )
242
+ await self._refresh_titles(task)
243
+ elif isinstance(signal, Message):
244
+ self._runs.set_last_message(run_id, signal.text)
245
+ elif isinstance(signal, UsageReported):
246
+ self._runs.record_usage(run_id, signal.usage)
247
+ await self._refresh_titles(task)
248
+ elif isinstance(signal, ToolStarted):
249
+ tool_running = True
250
+ elif isinstance(signal, ToolFinished):
251
+ tool_running = False
252
+ elif isinstance(signal, TurnEnded):
253
+ self._runs.set_last_message(run_id, signal.last_message)
254
+ return tool_running, signal
255
+ elif isinstance(signal, TurnFailed):
256
+ return tool_running, signal
257
+ else:
258
+ await self._record_auxiliary_signal(signal, agent=agent, task=task, role=role)
259
+ return tool_running, None
260
+
261
+ async def _record_auxiliary_signal(
262
+ self, signal: AgentSignal, *, agent: Agent, task: Task, role: Role
263
+ ) -> None:
264
+ if isinstance(signal, QuotaReported):
265
+ self._quotas.observe(agent.name, signal.windows)
266
+ elif isinstance(signal, Compacted):
267
+ self._events.append(
268
+ EventKind.COMPACTED,
269
+ task_id=task.id,
270
+ message=f"{task.id} {role.value} compacted context",
271
+ data={
272
+ "role": role.value,
273
+ "pre_tokens": signal.pre_tokens,
274
+ "post_tokens": signal.post_tokens,
275
+ },
276
+ )
277
+
278
+ async def _wait_for_signal(
279
+ self, signal_task: asyncio.Task[AgentSignal], timeout: float
280
+ ) -> bool:
281
+ timer_task = asyncio.create_task(self._clock.sleep(timeout))
282
+ try:
283
+ done, _ = await asyncio.wait(
284
+ {signal_task, timer_task}, return_when=asyncio.FIRST_COMPLETED
285
+ )
286
+ return signal_task in done
287
+ finally:
288
+ if not timer_task.done():
289
+ timer_task.cancel()
290
+ await asyncio.gather(timer_task, return_exceptions=True)
291
+
292
+ def _append_view(
293
+ self, run_dir: Path, turn_dir: Path, role: Role, rung: Rung, signal: AgentSignal
294
+ ) -> None:
295
+ path = view_log_path(run_dir, role.value)
296
+ make_private_dir(path.parent)
297
+ row = {
298
+ VIEW_TIME_KEY: self._clock.now().isoformat(),
299
+ VIEW_TURN_KEY: turn_dir.name,
300
+ VIEW_RUNG_KEY: asdict(rung),
301
+ VIEW_SIGNAL_KEY: type(signal).__name__,
302
+ **asdict(signal),
303
+ }
304
+ append_private_text(path, json.dumps(row, default=_json_value) + _LINE_ENDING)
305
+
306
+
307
+ def _json_value(value: object) -> str:
308
+ if isinstance(value, datetime):
309
+ return value.isoformat()
310
+ if isinstance(value, Enum):
311
+ return str(value.value)
312
+ message = f"cannot encode {type(value).__name__} in a turn view"
313
+ raise TypeError(message)
314
+
315
+
316
+ async def _next_signal(signals: AsyncIterator[AgentSignal]) -> AgentSignal:
317
+ return await signals.__anext__()
@@ -0,0 +1,124 @@
1
+ """Off-loop, prioritised and bounded notification delivery for the engine daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections import deque
8
+ from contextlib import suppress
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from logging import Logger
13
+
14
+ from shikiru.domain.events import Event
15
+ from shikiru.engine.notifier import Notifier
16
+
17
+ INFORMATIONAL_QUEUE_SIZE = 64
18
+
19
+
20
+ class NotificationDelivery:
21
+ """Delivers events on two tasks, so a slow hook never blocks the engine loop or an escalation.
22
+
23
+ Urgent events (escalations, engine faults, fault stops) have a delivery task of their own, so
24
+ a stuck informational hook never delays one by more than the urgent hook in flight; they are
25
+ never dropped, and only the informational backlog is bounded. Each lane delivers one event at
26
+ a time. An urgent event leaves its queue only after its delivery, so
27
+ `oldest_undelivered_urgent_id` covers the one in flight.
28
+ """
29
+
30
+ def __init__(self, notifier: Notifier) -> None:
31
+ self._notifier = notifier
32
+ self._urgent: deque[Event] = deque()
33
+ self._informational: deque[Event] = deque(maxlen=INFORMATIONAL_QUEUE_SIZE)
34
+ self._urgent_pending = asyncio.Event()
35
+ self._informational_pending = asyncio.Event()
36
+ self._urgent_task: asyncio.Task[None] | None = None
37
+ self._informational_task: asyncio.Task[None] | None = None
38
+ self._logger = logging.getLogger(__name__)
39
+
40
+ def start(self) -> None:
41
+ """Start the urgent and the informational delivery tasks."""
42
+ self._urgent_task = asyncio.create_task(self._deliver_urgent())
43
+ self._informational_task = asyncio.create_task(self._deliver_informational())
44
+
45
+ def enqueue(self, event: Event) -> None:
46
+ """Queue a notifiable `event`; a full informational backlog drops its oldest entry."""
47
+ if not self._notifier.wants(event):
48
+ return
49
+ if self._notifier.is_urgent(event):
50
+ self._urgent.append(event)
51
+ self._urgent_pending.set()
52
+ else:
53
+ if len(self._informational) == INFORMATIONAL_QUEUE_SIZE:
54
+ self._logger.warning(
55
+ "notification queue full; dropped event %s for event %s",
56
+ self._informational[0].id,
57
+ event.id,
58
+ )
59
+ self._informational.append(event)
60
+ self._informational_pending.set()
61
+
62
+ def replay(self, event: Event) -> None:
63
+ """Queue `event` after a restart when it is urgent; informational ones are not replayed."""
64
+ if self._notifier.is_urgent(event):
65
+ self.enqueue(event)
66
+
67
+ @property
68
+ def oldest_undelivered_urgent_id(self) -> int | None:
69
+ """Return the id of the oldest urgent event not yet delivered, if any."""
70
+ return self._urgent[0].id if self._urgent else None
71
+
72
+ def supervise(self, logger: Logger) -> None:
73
+ """Log a delivery task failure and start a replacement task."""
74
+ if _failed(self._urgent_task, logger):
75
+ self._urgent_task = asyncio.create_task(self._deliver_urgent())
76
+ if _failed(self._informational_task, logger):
77
+ self._informational_task = asyncio.create_task(self._deliver_informational())
78
+
79
+ async def shutdown(self) -> None:
80
+ """Cancel the delivery tasks, terminating running hooks, without draining the queues."""
81
+ tasks = (self._urgent_task, self._informational_task)
82
+ self._urgent_task = self._informational_task = None
83
+ for task in tasks:
84
+ if task is not None:
85
+ task.cancel()
86
+ for task in tasks:
87
+ if task is not None:
88
+ with suppress(asyncio.CancelledError):
89
+ await task
90
+
91
+ async def deliver_final(self, event: Event, timeout_seconds: float) -> None:
92
+ """Deliver `event` directly once the loop is over, giving up after `timeout_seconds`."""
93
+ try:
94
+ await asyncio.wait_for(self._notifier.notify(event), timeout_seconds)
95
+ except TimeoutError:
96
+ self._logger.warning("final notification for event %s timed out", event.id)
97
+
98
+ async def _deliver_urgent(self) -> None:
99
+ while True:
100
+ if self._urgent:
101
+ await self._notifier.notify(self._urgent[0])
102
+ self._urgent.popleft()
103
+ else:
104
+ self._urgent_pending.clear()
105
+ await self._urgent_pending.wait()
106
+
107
+ async def _deliver_informational(self) -> None:
108
+ while True:
109
+ if self._informational:
110
+ await self._notifier.notify(self._informational.popleft())
111
+ else:
112
+ self._informational_pending.clear()
113
+ await self._informational_pending.wait()
114
+
115
+
116
+ def _failed(task: asyncio.Task[None] | None, logger: Logger) -> bool:
117
+ if task is None or not task.done():
118
+ return False
119
+ if not task.cancelled() and (error := task.exception()) is not None:
120
+ logger.error(
121
+ "notification delivery failed",
122
+ exc_info=(type(error), error, error.__traceback__),
123
+ )
124
+ return True
@@ -0,0 +1,57 @@
1
+ """Feeds new events to notification delivery and keeps the delivered-up-to cursor durable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from shikiru.engine.notification_delivery import NotificationDelivery
9
+ from shikiru.store.events import EventLog
10
+ from shikiru.store.notification_cursor import NotificationCursor
11
+
12
+
13
+ class NotificationFeed:
14
+ """Queues events for delivery and persists the id every urgent event up to is delivered.
15
+
16
+ The cursor trails the oldest undelivered urgent event, so a crash or kill replays exactly the
17
+ urgent events (escalations, engine faults, fault stops) that were not yet delivered.
18
+ Informational events are never replayed. The cursor is written on the next engine poll after
19
+ a delivery, so an event delivered in that short window before a kill is delivered again.
20
+ """
21
+
22
+ def __init__(
23
+ self, events: EventLog, cursor: NotificationCursor, delivery: NotificationDelivery
24
+ ) -> None:
25
+ """Read events from `events`, store the cursor in `cursor`, queue into `delivery`."""
26
+ self._events = events
27
+ self._cursor = cursor
28
+ self._delivery = delivery
29
+ self._scanned = 0
30
+ self._persisted: int | None = None
31
+
32
+ def restore(self) -> None:
33
+ """Load the cursor and re-queue the urgent events it has not covered.
34
+
35
+ The first start of a project has no cursor and begins at the latest event.
36
+ """
37
+ self._persisted = self._cursor.read()
38
+ self._scanned = self._events.latest_id() if self._persisted is None else self._persisted
39
+ for event in self._events.since(self._scanned):
40
+ self._scanned = event.id
41
+ self._delivery.replay(event)
42
+ self.persist()
43
+
44
+ def scan(self) -> None:
45
+ """Queue the events appended since the last scan and persist the cursor."""
46
+ for event in self._events.since(self._scanned):
47
+ self._scanned = event.id
48
+ self._delivery.enqueue(event)
49
+ self.persist()
50
+
51
+ def persist(self) -> None:
52
+ """Write the cursor when it moved."""
53
+ oldest = self._delivery.oldest_undelivered_urgent_id
54
+ delivered_up_to = self._scanned if oldest is None else oldest - 1
55
+ if delivered_up_to != self._persisted:
56
+ self._cursor.write(delivered_up_to)
57
+ self._persisted = delivered_up_to
@@ -0,0 +1,91 @@
1
+ """tmux and external command notifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import shlex
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.domain.events import STOP_REASON_KEY, Event, EventKind
11
+
12
+ if TYPE_CHECKING:
13
+ from shikiru.engine.fault_reporter import FaultReporter
14
+ from shikiru.process import SubprocessRunner
15
+ from shikiru.tmux.client import Tmux
16
+
17
+ _URGENT = frozenset({EventKind.ESCALATION, EventKind.ENGINE_FAULT})
18
+ _NOTIFIABLE = frozenset(
19
+ {
20
+ EventKind.ESCALATION,
21
+ EventKind.TASK_DONE,
22
+ EventKind.ENGINE_FAULT,
23
+ EventKind.INTEGRATION_WAITING,
24
+ EventKind.TAKEOVER_LOST,
25
+ }
26
+ )
27
+ HOOK_FAULT_SCOPE = "notification hook"
28
+
29
+
30
+ class Notifier:
31
+ """Displays escalation/completion events in tmux and invokes `notify.command`."""
32
+
33
+ def __init__(
34
+ self,
35
+ tmux: Tmux,
36
+ runner: SubprocessRunner,
37
+ reporter: FaultReporter,
38
+ *,
39
+ session_target: str,
40
+ command: str,
41
+ timeout_seconds: float,
42
+ ) -> None:
43
+ """Use `tmux`, `runner`, the project session and an optional argv command.
44
+
45
+ A failing hook is recorded through `reporter`, once per streak.
46
+ """
47
+ self._tmux = tmux
48
+ self._runner = runner
49
+ self._reporter = reporter
50
+ self._session_target = session_target
51
+ self._command = command
52
+ self._timeout_seconds = timeout_seconds
53
+ self._logger = logging.getLogger(__name__)
54
+
55
+ @staticmethod
56
+ def wants(event: Event) -> bool:
57
+ """Return whether `event` is an escalation, completion, fault, merge wait or fault stop."""
58
+ return event.kind in _NOTIFIABLE or (
59
+ event.kind is EventKind.ENGINE_STOPPED and STOP_REASON_KEY in event.data
60
+ )
61
+
62
+ @staticmethod
63
+ def is_urgent(event: Event) -> bool:
64
+ """Return whether `event` is an escalation, an engine fault or a fault stop."""
65
+ return event.kind in _URGENT or (
66
+ event.kind is EventKind.ENGINE_STOPPED and STOP_REASON_KEY in event.data
67
+ )
68
+
69
+ async def notify(self, event: Event) -> None:
70
+ """Deliver `event` when it is an escalation or task completion."""
71
+ if not self.wants(event):
72
+ return
73
+ try:
74
+ await self._tmux.display_message(self._session_target, event.message)
75
+ except Exception:
76
+ self._logger.exception("tmux notification failed for event %s", event.id)
77
+ if self._command.strip():
78
+ try:
79
+ argv = tuple(shlex.split(self._command))
80
+ payload = json.dumps(event.model_dump(mode="json"))
81
+ await self._runner.run(
82
+ argv,
83
+ timeout=self._timeout_seconds,
84
+ input_text=payload,
85
+ check=True,
86
+ )
87
+ except Exception as error:
88
+ self._logger.exception("external notification failed for event %s", event.id)
89
+ self._reporter.report(error, scope=HOOK_FAULT_SCOPE)
90
+ else:
91
+ self._reporter.resolve(HOOK_FAULT_SCOPE)