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,337 @@
1
+ """Run the agent turns needed to resolve integration conflicts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.agents.contracts import role_dir
9
+ from shikiru.domain.commands import ResolutionKind
10
+ from shikiru.domain.events import EventKind
11
+ from shikiru.domain.recovery import TurnCounters
12
+ from shikiru.domain.reports import (
13
+ CURRENT_TURN_LINK_NAME,
14
+ DECISION_ACCEPT,
15
+ DECISION_CHANGES_REQUESTED,
16
+ FEEDBACK_REPORT_KINDS,
17
+ STATUS_COMPLETED,
18
+ ConsultAnswer,
19
+ FeedbackRework,
20
+ ImplementationReport,
21
+ ReportKind,
22
+ Verdict,
23
+ )
24
+ from shikiru.domain.steps import CONFLICT_ROUNDS, Transition
25
+ from shikiru.domain.tasks import EscalationReason, Role, Status, Step, Task
26
+ from shikiru.engine.events import REPORT_KIND_FIELD
27
+ from shikiru.engine.integration import (
28
+ ConflictRecovery,
29
+ FeedbackPending,
30
+ IntegrationBlocked,
31
+ IntegrationConflict,
32
+ IntegrationCoordinator,
33
+ IntegrationHeld,
34
+ IntegrationMetadata,
35
+ OffTaskBranch,
36
+ PullRequestClosed,
37
+ RebaseConflict,
38
+ UncommittedWork,
39
+ )
40
+ from shikiru.engine.step_runner import TurnStopped
41
+ from shikiru.errors import InvariantError
42
+ from shikiru.paths import run_dir, turns_dir
43
+ from shikiru.prompts.renderer import PromptName
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Awaitable, Callable
47
+ from pathlib import Path
48
+
49
+ from shikiru.domain.events import Event
50
+ from shikiru.engine.escalation_filing import EscalationFiler
51
+ from shikiru.engine.feedback import FeedbackRounds
52
+ from shikiru.engine.history import ReportHistory
53
+ from shikiru.engine.step_runner import StepRunner
54
+ from shikiru.store.events import EventLog
55
+ from shikiru.store.tasks import TaskStore
56
+
57
+
58
+ def _is_feedback_turn(event: Event) -> bool:
59
+ return event.data.get(REPORT_KIND_FIELD) in {kind.value for kind in FEEDBACK_REPORT_KINDS}
60
+
61
+
62
+ class IntegrationTurns:
63
+ """Coordinate conflict, consult and post-rebase verify turns."""
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ coordinator: IntegrationCoordinator,
69
+ events: EventLog,
70
+ history: ReportHistory,
71
+ project_root: Path,
72
+ tasks: TaskStore,
73
+ steps: StepRunner,
74
+ escalations: EscalationFiler,
75
+ feedback: FeedbackRounds | None = None,
76
+ ) -> None:
77
+ """Coordinate the integration protocol, running its turns through `steps`.
78
+
79
+ With `feedback`, a pull request's review rounds run here before it waits again.
80
+ """
81
+ self._coordinator = coordinator
82
+ self._events = events
83
+ self._history = history
84
+ self._project_root = project_root
85
+ self._tasks = tasks
86
+ self._steps = steps
87
+ self._escalations = escalations
88
+ self._feedback = feedback
89
+
90
+ async def run(self, task: Task) -> None:
91
+ """Run integration and its conflict turns, then escalate blocked integration.
92
+
93
+ Resolved conflicts whose post-rebase verify never ran (the flow was interrupted) are
94
+ verified before integrating, so no resolution merges unreviewed.
95
+ """
96
+
97
+ async def resolve_conflicts(
98
+ current: Task,
99
+ continue_rebase: Callable[[], Awaitable[None]],
100
+ recovery: ConflictRecovery,
101
+ ) -> tuple[Task, IntegrationMetadata]:
102
+ resolved = await self._resolve_conflicts(current, continue_rebase, recovery)
103
+ return resolved, self._history.integration_metadata(resolved)
104
+
105
+ try:
106
+ metadata = self._history.integration_metadata(task)
107
+ current = task
108
+ if self._feedback is not None and self._feedback.pending(task.id):
109
+ if task.slot is None:
110
+ self._queue(task)
111
+ return
112
+
113
+ async def prepare(candidate: Task) -> Task:
114
+ prepared, _ = await self._coordinator.prepare_publication(
115
+ candidate, resolve_conflicts, metadata, self._conflict_recovery
116
+ )
117
+ return prepared
118
+
119
+ await self._feedback.run(task, prepare)
120
+ current = self._tasks.require(task.id)
121
+ await self._coordinator.run(
122
+ current, resolve_conflicts, metadata, self._conflict_recovery
123
+ )
124
+ except FeedbackPending:
125
+ self._queue(task)
126
+ except TurnStopped, IntegrationHeld:
127
+ return
128
+ except (
129
+ IntegrationBlocked,
130
+ IntegrationConflict,
131
+ OffTaskBranch,
132
+ PullRequestClosed,
133
+ UncommittedWork,
134
+ ) as error:
135
+ current = self._tasks.require(task.id)
136
+ transition = Transition.escalate(current, error.reason, step=Step.INTEGRATE)
137
+ await self._escalations.escalate(current, transition, None, question=str(error))
138
+
139
+ def _queue(self, task: Task) -> None:
140
+ current = self._tasks.require(task.id)
141
+ self._tasks.apply_transition(
142
+ task.id, Transition.stay(current, step=Step.INTEGRATE, status=Status.QUEUED)
143
+ )
144
+ self._tasks.set_slot(task.id, None)
145
+ self._tasks.set_wait_reason(task.id, None)
146
+
147
+ def _conflict_recovery(self, task: Task) -> ConflictRecovery:
148
+ history = self._history.load(task.id)
149
+ events = self._events.since(
150
+ 0,
151
+ task_id=task.id,
152
+ kinds=(
153
+ EventKind.TURN_STARTED,
154
+ EventKind.TURN_COMPLETED,
155
+ EventKind.INTEGRATION_STARTED,
156
+ EventKind.FEEDBACK_REBASE_STARTED,
157
+ EventKind.ESCALATION,
158
+ EventKind.ESCALATION_RESOLVED,
159
+ EventKind.TASK_RETRIED,
160
+ ),
161
+ )
162
+ events = [event for event in events if not _is_feedback_turn(event)]
163
+ implementer_turns = [
164
+ event
165
+ for event in events
166
+ if event.kind is EventKind.TURN_COMPLETED
167
+ and event.data.get("step") == Step.INTEGRATE.value
168
+ and event.data.get("role") == Role.IMPLEMENTER.value
169
+ and event.data.get("outcome") == STATUS_COMPLETED
170
+ ]
171
+ turn_starts = [
172
+ event
173
+ for event in events
174
+ if event.kind is EventKind.TURN_STARTED
175
+ and event.data.get("step") == Step.INTEGRATE.value
176
+ and event.data.get("role") == Role.IMPLEMENTER.value
177
+ ]
178
+ escalations = {
179
+ event.data.get("escalation_id"): event
180
+ for event in events
181
+ if event.kind is EventKind.ESCALATION
182
+ }
183
+ retry_events = [
184
+ event
185
+ for event in events
186
+ if event.kind is EventKind.TASK_RETRIED
187
+ or (
188
+ event.kind is EventKind.ESCALATION_RESOLVED
189
+ and event.data.get("resolution") == ResolutionKind.RETRY.value
190
+ and (escalation := escalations.get(event.data.get("escalation_id"))) is not None
191
+ and escalation.data.get("reason")
192
+ in {
193
+ EscalationReason.INTEGRATION_BLOCKED.value,
194
+ EscalationReason.INTEGRATION_CONFLICT.value,
195
+ }
196
+ )
197
+ ]
198
+ reset_events = [
199
+ event
200
+ for event in events
201
+ if event.kind in (EventKind.INTEGRATION_STARTED, EventKind.FEEDBACK_REBASE_STARTED)
202
+ or (
203
+ event.kind is EventKind.TURN_COMPLETED
204
+ and event.data.get("step") == Step.INTEGRATE.value
205
+ and event.data.get("role") == Role.LEADER.value
206
+ and event.data.get("outcome") == DECISION_CHANGES_REQUESTED
207
+ )
208
+ ] + retry_events
209
+ latest_reset = max((event.id for event in reset_events), default=0)
210
+ completed_turns = sum(event.id > latest_reset for event in implementer_turns)
211
+ recovery = ConflictRecovery(completed_turns=completed_turns)
212
+ if task.step is not Step.INTEGRATE or not history:
213
+ return recovery
214
+ label, report = history[-1]
215
+ if (
216
+ not isinstance(report, ImplementationReport)
217
+ or isinstance(report, FeedbackRework)
218
+ or report.status != STATUS_COMPLETED
219
+ ):
220
+ return recovery
221
+ completed = [event for event in implementer_turns if event.data.get("turn") == label]
222
+ started = [event for event in turn_starts if event.data.get("turn") == label]
223
+ if not completed and not started:
224
+ return recovery
225
+ report_event = completed[-1] if completed else started[-1]
226
+ latest_start = max(
227
+ (
228
+ event.id
229
+ for event in events
230
+ if event.kind in (EventKind.INTEGRATION_STARTED, EventKind.FEEDBACK_REBASE_STARTED)
231
+ ),
232
+ default=0,
233
+ )
234
+ later_turn_started = any(event.id > report_event.id for event in turn_starts)
235
+ if completed and not later_turn_started:
236
+ recovery = replace(recovery, force_new_turn=True)
237
+ if (
238
+ report_event.id > latest_start
239
+ and (completed or self._current_turn_is(task, label))
240
+ and not later_turn_started
241
+ ):
242
+ return replace(recovery, resumed_report=True)
243
+ retry_after_report = any(
244
+ report_event.id < event.id
245
+ and event.id > latest_start
246
+ and not any(start.id > event.id for start in turn_starts)
247
+ for event in retry_events
248
+ )
249
+ return replace(recovery, resumed_report=retry_after_report)
250
+
251
+ def _current_turn_is(self, task: Task, label: str) -> bool:
252
+ current_turn = (
253
+ role_dir(run_dir(self._project_root, task.id), Role.IMPLEMENTER.value)
254
+ / CURRENT_TURN_LINK_NAME
255
+ )
256
+ report_turn = turns_dir(self._project_root, task.id) / label
257
+ return current_turn.is_symlink() and current_turn.resolve() == report_turn.resolve()
258
+
259
+ async def _resolve_conflicts(
260
+ self,
261
+ task: Task,
262
+ continue_rebase: Callable[[], Awaitable[None]],
263
+ recovery: ConflictRecovery,
264
+ ) -> Task:
265
+ current = task
266
+ context = ""
267
+ conflict_turns = max(recovery.completed_turns, int(recovery.resumed_report))
268
+ resolved = recovery.resumed_report
269
+ while True:
270
+ if not resolved:
271
+ if conflict_turns >= CONFLICT_ROUNDS:
272
+ raise IntegrationConflict.unresolved_rebase(current.id, conflict_turns, context)
273
+ current, context, resolved = await self._conflict_turn(
274
+ current, context, recovery.force_new_turn
275
+ )
276
+ if resolved:
277
+ conflict_turns += 1
278
+ recovery = replace(recovery, force_new_turn=False)
279
+ continue
280
+ try:
281
+ await continue_rebase()
282
+ except RebaseConflict as error:
283
+ context = str(error)
284
+ resolved = False
285
+ recovery = replace(recovery, resumed_report=False, force_new_turn=True)
286
+ continue
287
+ current, verdict = await self._verify_conflicts(current)
288
+ if verdict.decision == DECISION_ACCEPT:
289
+ return current
290
+ context = "; ".join(issue.description for issue in verdict.issues)
291
+ conflict_turns = 0
292
+ resolved = False
293
+ recovery = ConflictRecovery()
294
+
295
+ async def _conflict_turn(
296
+ self, task: Task, context: str, force_new_turn: bool
297
+ ) -> tuple[Task, str, bool]:
298
+ step = Step.CONSULT if task.step is Step.CONSULT else Step.INTEGRATE
299
+ current, _, stopped = await self._steps.run_step(
300
+ task,
301
+ step,
302
+ TurnCounters(),
303
+ conflict_context=context,
304
+ force_new_turn=step is Step.INTEGRATE and force_new_turn,
305
+ )
306
+ if stopped:
307
+ raise TurnStopped
308
+ latest = self._history.latest_report(current.id)
309
+ if step is Step.CONSULT:
310
+ if not isinstance(latest, ConsultAnswer):
311
+ message = f"{current.id} integration consult produced no answer"
312
+ raise InvariantError(message)
313
+ return current, latest.answer, False
314
+ if not isinstance(latest, ImplementationReport):
315
+ message = f"{current.id} conflicts turn produced no implementation report"
316
+ raise InvariantError(message)
317
+ if latest.status == STATUS_COMPLETED:
318
+ return current, context, True
319
+ if current.step is not Step.CONSULT:
320
+ message = f"{current.id} conflict report did not route to consult"
321
+ raise InvariantError(message)
322
+ return current, context, False
323
+
324
+ async def _verify_conflicts(self, task: Task) -> tuple[Task, Verdict]:
325
+ current, outcome = await self._steps.run_report_turn(
326
+ task,
327
+ role=Role.LEADER,
328
+ template=PromptName.VERIFY,
329
+ kind=ReportKind.VERDICT,
330
+ )
331
+ if not isinstance(outcome, Verdict):
332
+ message = f"{task.id} post-rebase turn produced no verdict"
333
+ raise InvariantError(message)
334
+ updated, escalated = await self._steps.advance(current, outcome, role=Role.LEADER)
335
+ if escalated:
336
+ raise TurnStopped
337
+ return updated, outcome
@@ -0,0 +1,362 @@
1
+ """Task window creation and periodic pane restoration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.config.settings import LayoutSplit, PaneLeaf
10
+ from shikiru.domain.commands import HandBack
11
+ from shikiru.domain.events import EventKind
12
+ from shikiru.domain.tasks import ACTIVE_STATUSES, Role, Task
13
+ from shikiru.errors import InvariantError
14
+ from shikiru.paths import task_root
15
+ from shikiru.private import make_private_dir
16
+ from shikiru.tmux.client import (
17
+ FORMAT_LAYOUT_PANE,
18
+ FORMAT_PANE_DEAD,
19
+ FORMAT_PANE_ID,
20
+ FORMAT_WINDOW_ID,
21
+ LAYOUT_PANE_OPTION,
22
+ PANE_DEAD_FLAG,
23
+ )
24
+ from shikiru.tmux.decorator import render_title
25
+ from shikiru.view_log import view_argv
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Awaitable, Callable
29
+ from pathlib import Path
30
+
31
+ from shikiru.config.settings import Settings
32
+ from shikiru.engine.fault_reporter import FaultReporter
33
+ from shikiru.store.commands import CommandQueue
34
+ from shikiru.store.events import EventLog
35
+ from shikiru.store.runs import RunStore
36
+ from shikiru.store.tasks import TaskStore
37
+ from shikiru.tmux.client import Tmux
38
+ from shikiru.tmux.decorator import PaneDecorator
39
+ from shikiru.tmux.layout import LayoutBuilder
40
+ from shikiru.tmux.session import SessionView
41
+
42
+ _SHELL_ARGV = ("/bin/sh",)
43
+ _ROLE_PANE_NAMES = frozenset((Role.LEADER.value, Role.IMPLEMENTER.value))
44
+ _SHELL_PANE_NAME = "shell"
45
+ _LAYOUT_PANE_SEPARATOR = "\t"
46
+ _LAYOUT_PANE_FORMAT = f"{FORMAT_PANE_ID}{_LAYOUT_PANE_SEPARATOR}{FORMAT_LAYOUT_PANE}"
47
+ _SNAPSHOT_PANE_FORMAT = _LAYOUT_PANE_SEPARATOR.join(
48
+ (FORMAT_WINDOW_ID, FORMAT_PANE_ID, FORMAT_PANE_DEAD, FORMAT_LAYOUT_PANE)
49
+ )
50
+ _SNAPSHOT_FIELD_COUNT = 4
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class _PaneRow:
55
+ window_id: str
56
+ pane_id: str
57
+ dead: bool
58
+ layout_name: str
59
+
60
+
61
+ def _parse_snapshot(lines: list[str]) -> list[_PaneRow]:
62
+ rows: list[_PaneRow] = []
63
+ for line in lines:
64
+ window_id, pane_id, dead, layout_name = line.split(
65
+ _LAYOUT_PANE_SEPARATOR, _SNAPSHOT_FIELD_COUNT - 1
66
+ )
67
+ rows.append(_PaneRow(window_id, pane_id, dead == PANE_DEAD_FLAG, layout_name))
68
+ return rows
69
+
70
+
71
+ class LayoutGuard:
72
+ """Keeps each task's tmux window and non-agent panes aligned with persisted state."""
73
+
74
+ def __init__(
75
+ self,
76
+ *,
77
+ tmux: Tmux,
78
+ view: SessionView,
79
+ builder: LayoutBuilder,
80
+ decorator: PaneDecorator,
81
+ session_target: str,
82
+ tasks: TaskStore,
83
+ settings: Settings,
84
+ project_root: Path,
85
+ events: EventLog,
86
+ runs: RunStore,
87
+ commands: CommandQueue,
88
+ faults: FaultReporter,
89
+ refresh_titles: Callable[[Task], Awaitable[None]],
90
+ ) -> None:
91
+ """Create windows through `tmux`, storing pane ids through `tasks`.
92
+
93
+ A taken-over role's pane exiting is queued as a `HandBack` on `commands`. A task
94
+ whose restoration fails is recorded through `faults` without stopping the others. Role pane
95
+ titles are re-applied through `refresh_titles` whenever role panes are created or rebuilt.
96
+ """
97
+ self._tmux = tmux
98
+ self._session_target = session_target
99
+ self._tasks = tasks
100
+ self._settings = settings
101
+ self._project_root = project_root
102
+ self._events = events
103
+ self._view = view
104
+ self._builder = builder
105
+ self._decorator = decorator
106
+ self._runs = runs
107
+ self._commands = commands
108
+ self._faults = faults
109
+ self._refresh_titles = refresh_titles
110
+ self._layout_pane_names = _layout_pane_names(settings.dashboard.layout)
111
+ self._special_pane_names = tuple(
112
+ name for name in self._layout_pane_names if name not in _ROLE_PANE_NAMES
113
+ )
114
+ self._special_panes: dict[str, dict[str, str]] = {}
115
+ self._windows = asyncio.Lock()
116
+
117
+ async def ensure(self, task: Task) -> Task:
118
+ """Create `task`'s window or restore missing panes, returning fresh task state."""
119
+ if task.window_id is None or not await self._tmux.target_exists(task.window_id):
120
+ created = await self._create(task)
121
+ return await self._refresh_cache(created.id)
122
+ missing_agents = await self._missing_agent_roles(task)
123
+ missing_special = await self._missing_special_panes(task)
124
+ if missing_agents or missing_special:
125
+ await self._restore_missing_panes(task, missing_agents)
126
+ await self._restore_special_panes(task)
127
+ restored = await self._refresh_cache(task.id)
128
+ self._release_lost_takeovers(task.id, missing_agents)
129
+ await self._restore_view_panes(restored)
130
+ if missing_agents:
131
+ self._record_restored(task.id, "agent")
132
+ for pane in missing_special:
133
+ self._record_restored(task.id, pane)
134
+ return restored
135
+
136
+ async def restore(self, tasks: list[Task]) -> None:
137
+ """Restore the windows and dead panes of `tasks` that are still active when reached.
138
+
139
+ A task that cannot be restored is recorded as an `engine_fault` and skipped.
140
+ """
141
+ snapshot: list[_PaneRow] | None = None
142
+ for task in tasks:
143
+ async with self._faults.isolate("layout restore", task_id=task.id), self._windows:
144
+ current = self._tasks.get(task.id)
145
+ if current is None or current.status not in ACTIVE_STATUSES:
146
+ continue
147
+ if snapshot is None:
148
+ snapshot = _parse_snapshot(
149
+ await self._tmux.list_session_panes(
150
+ self._session_target, _SNAPSHOT_PANE_FORMAT
151
+ )
152
+ )
153
+ if self._is_healthy(current, snapshot):
154
+ continue
155
+ snapshot = None
156
+ await self.ensure(current)
157
+
158
+ def _is_healthy(self, task: Task, snapshot: list[_PaneRow]) -> bool:
159
+ if task.window_id is None:
160
+ return False
161
+ window_rows = [row for row in snapshot if row.window_id == task.window_id]
162
+ if not window_rows:
163
+ return False
164
+ session_panes = {row.pane_id for row in snapshot}
165
+ for role in (Role.LEADER, Role.IMPLEMENTER):
166
+ if task.panes.get(role) not in session_panes:
167
+ return False
168
+ window_names = {row.layout_name for row in window_rows}
169
+ if not all(name in window_names for name in self._special_pane_names):
170
+ return False
171
+ managed = set(task.panes.values())
172
+ managed.update(
173
+ row.pane_id for row in window_rows if row.layout_name in self._special_pane_names
174
+ )
175
+ return not any(row.dead and row.pane_id in managed for row in snapshot)
176
+
177
+ async def close(self, task: Task) -> None:
178
+ """Close a task window when its task reaches a terminal state."""
179
+ async with self._windows:
180
+ if task.window_id is not None and await self._tmux.target_exists(task.window_id):
181
+ await self._view.close_task_window(task.window_id)
182
+ self._special_panes.pop(task.id, None)
183
+
184
+ async def _create(self, task: Task) -> Task:
185
+ root = task_root(self._project_root, task.id)
186
+ make_private_dir(root)
187
+ fields = {"task_id": task.id, "title": task.title}
188
+ window_name = render_title(self._settings.dashboard.titles.window, fields)
189
+ base = await self._view.create_task_window(
190
+ self._session_target,
191
+ window_name=window_name,
192
+ cwd=root,
193
+ )
194
+ window_id = await self._tmux.window_id(base)
195
+ panes = await self._builder.build(base, self._settings.dashboard.layout, cwd=root)
196
+ role_panes = {
197
+ Role(name): pane_id for name, pane_id in panes.items() if name in _ROLE_PANE_NAMES
198
+ }
199
+ self._special_panes[task.id] = {
200
+ name: pane_id for name, pane_id in panes.items() if name in self._special_pane_names
201
+ }
202
+ await self._decorate_special_panes(root, panes)
203
+ await self._decorator.configure_formats(window_id)
204
+ for role, pane_id in role_panes.items():
205
+ await self.start_view(task.id, role, pane_id)
206
+ self._tasks.set_window(task.id, window_id)
207
+ self._tasks.set_panes(task.id, role_panes)
208
+ self._release_lost_takeovers(task.id, set(role_panes))
209
+ updated = self._tasks.get(task.id)
210
+ if updated is None:
211
+ message = f"{task.id} vanished while recording its task window"
212
+ raise InvariantError(message)
213
+ await self._refresh_titles(updated)
214
+ return updated
215
+
216
+ async def _refresh_cache(self, task_id: str) -> Task:
217
+ refreshed = self._tasks.get(task_id)
218
+ if refreshed is None:
219
+ message = f"{task_id} vanished while refreshing its task window"
220
+ raise InvariantError(message)
221
+ return refreshed
222
+
223
+ async def _missing_agent_roles(self, task: Task) -> set[Role]:
224
+ missing: set[Role] = set()
225
+ for role in (Role.LEADER, Role.IMPLEMENTER):
226
+ pane_id = task.panes.get(role)
227
+ if pane_id is None or not await self._tmux.pane_exists(pane_id):
228
+ missing.add(role)
229
+ return missing
230
+
231
+ async def _missing_special_panes(self, task: Task) -> set[str]:
232
+ if task.window_id is None:
233
+ message = f"{task.id} has no window while checking special panes"
234
+ raise InvariantError(message)
235
+ special = self._special_panes.setdefault(task.id, {})
236
+ pane_rows = await self._tmux.list_panes(task.window_id, _LAYOUT_PANE_FORMAT)
237
+ window_panes = {row.split(_LAYOUT_PANE_SEPARATOR, 1)[0] for row in pane_rows}
238
+ for name, pane_id in tuple(special.items()):
239
+ if pane_id not in window_panes:
240
+ del special[name]
241
+ for row in pane_rows:
242
+ pane_id, name = row.split(_LAYOUT_PANE_SEPARATOR, 1)
243
+ if name in self._special_pane_names:
244
+ special[name] = pane_id
245
+ available = [
246
+ pane_id
247
+ for pane_id in window_panes
248
+ if pane_id not in task.panes.values() and pane_id not in special.values()
249
+ ]
250
+ available.sort()
251
+ for name in self._special_pane_names:
252
+ if name not in special and available:
253
+ special[name] = available.pop(0)
254
+ await self._tmux.set_pane_option(special[name], LAYOUT_PANE_OPTION, name)
255
+ return {name for name in self._special_pane_names if name not in special}
256
+
257
+ async def _restore_missing_panes(self, task: Task, missing_agents: set[Role]) -> None:
258
+ if task.window_id is None:
259
+ message = f"{task.id} has no window while restoring panes"
260
+ raise InvariantError(message)
261
+ root = task_root(self._project_root, task.id)
262
+ existing = {
263
+ role.value: pane_id
264
+ for role, pane_id in task.panes.items()
265
+ if await self._tmux.pane_exists(pane_id)
266
+ }
267
+ special = self._special_panes.setdefault(task.id, {})
268
+ existing.update(
269
+ {
270
+ name: pane_id
271
+ for name, pane_id in special.items()
272
+ if await self._tmux.pane_exists(pane_id)
273
+ }
274
+ )
275
+ panes = await self._builder.restore_missing(
276
+ self._settings.dashboard.layout, existing, cwd=root
277
+ )
278
+ await self._builder.restore_vertical_stack(
279
+ task.window_id, self._settings.dashboard.layout, panes
280
+ )
281
+ role_panes = {Role(name): panes[name] for name in _ROLE_PANE_NAMES if name in panes}
282
+ special.clear()
283
+ special.update({name: panes[name] for name in self._special_pane_names})
284
+ await self._decorate_special_panes(root, panes)
285
+ for role in missing_agents:
286
+ await self.start_view(task.id, role, role_panes[role])
287
+ self._tasks.set_panes(task.id, role_panes)
288
+ await self._refresh_titles(await self._refresh_cache(task.id))
289
+
290
+ async def _restore_special_panes(self, task: Task) -> None:
291
+ special = self._special_panes.get(task.id, {})
292
+ root = task_root(self._project_root, task.id)
293
+ for name, pane_id in special.items():
294
+ if not await self._tmux.is_pane_dead(pane_id):
295
+ continue
296
+ await self._tmux.respawn_pane(pane_id, _SHELL_ARGV, env={}, cwd=root)
297
+ await self._title_special_pane(name, pane_id, root)
298
+ self._record_restored(task.id, name)
299
+
300
+ async def _restore_view_panes(self, task: Task) -> None:
301
+ for role, pane_id in task.panes.items():
302
+ if not await self._tmux.is_pane_dead(pane_id):
303
+ continue
304
+ if self._runs.get_taken_over(task.id, role) is not None:
305
+ self._queue_hand_back(HandBack(task_id=task.id, role=role))
306
+ continue
307
+ await self.start_view(task.id, role, pane_id)
308
+ self._record_restored(task.id, role.value)
309
+
310
+ def _release_lost_takeovers(self, task_id: str, lost_roles: set[Role]) -> None:
311
+ for role in sorted(lost_roles):
312
+ if self._runs.get_taken_over(task_id, role) is not None and self._queue_hand_back(
313
+ HandBack(task_id=task_id, role=role)
314
+ ):
315
+ self._events.append(
316
+ EventKind.TAKEOVER_LOST,
317
+ task_id=task_id,
318
+ message=f"{task_id} {role.value} takeover lost its pane; handing back",
319
+ data={"role": role.value},
320
+ )
321
+
322
+ def _queue_hand_back(self, command: HandBack) -> bool:
323
+ if any(
324
+ isinstance(queued.command, HandBack)
325
+ and (queued.command.task_id, queued.command.role) == (command.task_id, command.role)
326
+ for queued in self._commands.pending()
327
+ ):
328
+ return False
329
+ self._commands.append(command)
330
+ return True
331
+
332
+ async def _decorate_special_panes(self, root: Path, panes: dict[str, str]) -> None:
333
+ for name in self._special_pane_names:
334
+ pane_id = panes[name]
335
+ await self._tmux.set_pane_option(pane_id, LAYOUT_PANE_OPTION, name)
336
+ await self._title_special_pane(name, pane_id, root)
337
+
338
+ async def _title_special_pane(self, name: str, pane_id: str, root: Path) -> None:
339
+ if name == _SHELL_PANE_NAME:
340
+ await self._decorator.set_pane_title(
341
+ pane_id, self._settings.dashboard.titles.shell, {"cwd": str(root)}
342
+ )
343
+
344
+ async def start_view(self, task_id: str, role: Role, pane_id: str) -> None:
345
+ """Run `role`'s streaming view of `task_id` in `pane_id`."""
346
+ argv = view_argv(self._project_root, task_id, role.value)
347
+ root = task_root(self._project_root, task_id)
348
+ await self._tmux.respawn_pane(pane_id, argv, env={}, cwd=root)
349
+
350
+ def _record_restored(self, task_id: str, pane: str) -> None:
351
+ self._events.append(
352
+ EventKind.PANE_RESTORED,
353
+ task_id=task_id,
354
+ message=f"{task_id} pane restored: {pane}",
355
+ data={"pane": pane},
356
+ )
357
+
358
+
359
+ def _layout_pane_names(node: LayoutSplit | PaneLeaf) -> tuple[str, ...]:
360
+ if isinstance(node, PaneLeaf):
361
+ return (node.pane,)
362
+ return tuple(name for child in node.panes for name in _layout_pane_names(child))