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,1234 @@
1
+ """Rebase, conflict hand-off, local merge and pull request orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import dataclasses
7
+ from contextlib import nullcontext
8
+ from dataclasses import dataclass, replace
9
+ from datetime import timedelta
10
+ from typing import TYPE_CHECKING, Protocol, cast
11
+
12
+ from shikiru.config.settings import (
13
+ MERGE_INTEGRATION,
14
+ PULL_REQUEST_INTEGRATION,
15
+ SQUASH_INTEGRATION,
16
+ )
17
+ from shikiru.domain.events import EventKind
18
+ from shikiru.domain.faults import next_backoff
19
+ from shikiru.domain.results import (
20
+ CHANGES_DELETIONS_KEY,
21
+ CHANGES_FILES_KEY,
22
+ CHANGES_INSERTIONS_KEY,
23
+ DONE_CHANGES_KEY,
24
+ DONE_INTEGRATION_KEY,
25
+ landed_changes,
26
+ )
27
+ from shikiru.domain.scheduling import WaitReason
28
+ from shikiru.domain.steps import Transition
29
+ from shikiru.domain.tasks import EscalationReason, RepoState, Status, Task
30
+ from shikiru.errors import CommandFailed, CommandTimeoutError, InvariantError, ShikiruError
31
+ from shikiru.paths import task_root
32
+ from shikiru.workspace.base_refs import BaseFetchFailed, LocalBaseBlocked, MergedBaseUnavailable
33
+ from shikiru.workspace.facts import describe_off_branch
34
+ from shikiru.workspace.github import PullRequestState
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Awaitable, Callable, Sequence
38
+ from contextlib import AbstractContextManager
39
+ from pathlib import Path
40
+
41
+ from shikiru.clock import Clock
42
+ from shikiru.config.settings import OrchestrationConfig
43
+ from shikiru.engine.fault_reporter import FaultReporter
44
+ from shikiru.store.events import EventLog
45
+ from shikiru.store.tasks import TaskStore
46
+ from shikiru.workspace.base_refs import BaseRefs, FetchedBaseRefs, LocalBaseSync
47
+ from shikiru.workspace.git import Git
48
+ from shikiru.workspace.github import GitHub, PullRequestStatus
49
+
50
+ INTEGRATION_RETRY = timedelta(minutes=2)
51
+ """Delay between blocked integration pre-checks."""
52
+
53
+ POLL_FAULT_SCOPE = "pull request poll"
54
+ GIT_FAILURE_DETAIL_LIMIT = 4000
55
+ GIT_FAILURE_TRUNCATION = " [truncated]"
56
+ SQUASH_TASK_TRAILER = "Shikiru-Task"
57
+ SQUASH_TIP_TRAILER = "Shikiru-Squashed-Tip"
58
+ _APPLYING_BASE_KEY = "base"
59
+ _APPLYING_BASE_SHA_KEY = "base_sha"
60
+ _APPLYING_BRANCH_KEY = "branch"
61
+ _APPLYING_TASK_SHA_KEY = "task_sha"
62
+ _APPLYING_LANDING_SHA_KEY = "landing_sha"
63
+ _INTEGRATION_COMMITS_KEY = "commits"
64
+ _PR_BASE_SHA_KEY = "base_sha"
65
+ _PR_BRANCH_SHA_KEY = "branch_sha"
66
+ PUBLISHED_BASE_KEY = "base_sha"
67
+ PUBLISHED_TIP_KEY = "sha"
68
+ _MERGED_PR_WITHOUT_COMMIT = "merged pull request has no landing commit"
69
+ _MERGED_PR_COMMIT_WITHOUT_PARENT = "merged pull request has no parent commit"
70
+ _LOCAL_LANDING_WITHOUT_TASK_TIP = "cannot recover a local landing without its task branch tip"
71
+ _LOCAL_TASK_BRANCH_ADVANCED = "task branch advanced after the local landing; preserving its work"
72
+
73
+ _PR_ENDED_BY = frozenset((EscalationReason.PR_CLOSED,))
74
+ _INTEGRATION_RESTARTED_BY = _PR_ENDED_BY | {EscalationReason.INTEGRATION_CONFLICT}
75
+
76
+
77
+ class IntegrationBlocked(ShikiruError):
78
+ """An integration prerequisite or Git operation could not complete."""
79
+
80
+ reason = EscalationReason.INTEGRATION_BLOCKED
81
+
82
+ def __init__(self, message: str = "integration remained blocked") -> None:
83
+ """Build the integration-blocked error, saying what stayed blocked."""
84
+ super().__init__(message)
85
+
86
+
87
+ class PullRequestStalled(IntegrationBlocked):
88
+ """An open pull request cannot land: it conflicts, fails its checks or waited too long."""
89
+
90
+ def __init__(self, url: str, state: str) -> None:
91
+ """Build the error for the PR at `url`, saying which `state` it is stuck in."""
92
+ super().__init__(f"pull request {url} is {state}")
93
+
94
+
95
+ class IntegrationHeld(Exception):
96
+ """A task hold was set before integration could start its next external effect."""
97
+
98
+
99
+ class IntegrationConflict(ShikiruError):
100
+ """Conflict recovery could not produce an accepted post-rebase result."""
101
+
102
+ reason = EscalationReason.INTEGRATION_CONFLICT
103
+
104
+ def __init__(self, message: str = "integration conflicts could not be resolved") -> None:
105
+ """Build the integration-conflict error, saying what conflicted."""
106
+ super().__init__(message)
107
+
108
+ @classmethod
109
+ def unresolved_rebase(cls, task_id: str, rounds: int, context: str) -> IntegrationConflict:
110
+ """Describe paths that remain unresolved after the bounded conflict turns."""
111
+ return cls(
112
+ f"{task_id}: the rebase still has unresolved paths after "
113
+ f"{rounds} conflict turns: {context}"
114
+ )
115
+
116
+
117
+ class RebaseConflict(Exception):
118
+ """Git still reports unmerged paths after a conflict turn."""
119
+
120
+ def __init__(self, paths: Sequence[str]) -> None:
121
+ self.paths = tuple(paths)
122
+ super().__init__(f"rebase still has unmerged paths: {', '.join(self.paths)}")
123
+
124
+
125
+ class PullRequestConflicting(IntegrationConflict):
126
+ """An open pull request conflicts with its base: only a new rebase can resolve it."""
127
+
128
+ def __init__(self, url: str) -> None:
129
+ """Build the error for the PR at `url`."""
130
+ super().__init__(f"pull request {url} is conflicting with its base")
131
+
132
+
133
+ class UncommittedWork(ShikiruError):
134
+ """The task worktree holds uncommitted changes that integration would discard."""
135
+
136
+ reason = EscalationReason.UNCOMMITTED_WORK
137
+
138
+ def __init__(self, task_id: str, paths: Sequence[str]) -> None:
139
+ """Build the error for `task_id` listing the porcelain status lines in `paths`."""
140
+ listed = ", ".join(line.strip() for line in paths)
141
+ super().__init__(f"{task_id} has uncommitted changes that cannot be integrated: {listed}")
142
+
143
+
144
+ class OffTaskBranch(ShikiruError):
145
+ """The task worktree's `HEAD` is off the task branch, so merging the branch loses its work."""
146
+
147
+ reason = EscalationReason.OFF_TASK_BRANCH
148
+
149
+ def __init__(
150
+ self, task_id: str, branch: str, head_branch: str | None, commits: Sequence[str]
151
+ ) -> None:
152
+ """Build the error for `task_id`, naming where `HEAD` is and the commits at risk."""
153
+ super().__init__(f"{task_id}: {describe_off_branch(branch, head_branch, commits)}")
154
+
155
+
156
+ class PullRequestClosed(ShikiruError):
157
+ """The task's pull request was closed without being merged."""
158
+
159
+ reason = EscalationReason.PR_CLOSED
160
+
161
+ def __init__(self, url: str) -> None:
162
+ """Build the pull-request-closed error for the PR at `url`."""
163
+ super().__init__(f"pull request closed without merging: {url}")
164
+
165
+
166
+ class FeedbackPending(Exception):
167
+ """Review feedback awaits a round, so the pull request wait ends to queue for one."""
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class IntegrationSummary:
172
+ """Observable result of a successful integration."""
173
+
174
+ summary: str
175
+ commits: tuple[str, ...] = ()
176
+ residual_risks: tuple[str, ...] = ()
177
+
178
+
179
+ @dataclass(frozen=True, slots=True)
180
+ class LandedChanges:
181
+ """Final change totals of work as it landed in its base."""
182
+
183
+ files: int
184
+ insertions: int
185
+ deletions: int
186
+
187
+ def as_data(self) -> dict[str, int]:
188
+ """Return the totals as recorded in event data."""
189
+ return {
190
+ CHANGES_FILES_KEY: self.files,
191
+ CHANGES_INSERTIONS_KEY: self.insertions,
192
+ CHANGES_DELETIONS_KEY: self.deletions,
193
+ }
194
+
195
+
196
+ @dataclass(frozen=True, slots=True)
197
+ class Landing:
198
+ """The commits an integration put in its base and the final totals of what they changed.
199
+
200
+ A landing without `changes` is one whose totals are not known.
201
+ """
202
+
203
+ commits: tuple[str, ...] = ()
204
+ integration: str | None = None
205
+ changes: LandedChanges | None = None
206
+
207
+ def done_data(self) -> dict[str, object]:
208
+ """Return the evidence `TASK_DONE` records about what landed."""
209
+ data: dict[str, object] = {"commits": list(self.commits)}
210
+ if self.changes is not None:
211
+ data[DONE_INTEGRATION_KEY] = self.integration
212
+ data[DONE_CHANGES_KEY] = self.changes.as_data()
213
+ return data
214
+
215
+
216
+ @dataclass(frozen=True, slots=True)
217
+ class IntegrationMetadata:
218
+ """Accepted Leader context carried into the terminal task event and its commit message."""
219
+
220
+ subject: str
221
+ summary: str
222
+ residual_risks: tuple[str, ...] = ()
223
+
224
+
225
+ @dataclass(frozen=True, slots=True)
226
+ class MergeRequest:
227
+ """A rebased task branch ready to be applied to its base."""
228
+
229
+ task_id: str
230
+ title: str
231
+ subject: str
232
+ repo: RepoState
233
+ task_root: Path
234
+ summary: str
235
+ held: Callable[[], bool]
236
+
237
+
238
+ @dataclass(frozen=True, slots=True)
239
+ class _MergeCandidate:
240
+ base_sha: str
241
+ branch_sha: str
242
+ landing_sha: str
243
+ base_checked_out: bool
244
+ landing: Landing
245
+
246
+
247
+ @dataclass(frozen=True, slots=True)
248
+ class ConflictRecovery:
249
+ """Durable conflict-turn progress passed from the integration flow to its coordinator."""
250
+
251
+ resumed_report: bool = False
252
+ completed_turns: int = 0
253
+ force_new_turn: bool = False
254
+
255
+
256
+ class _PrecheckFailed(Exception):
257
+ pass
258
+
259
+
260
+ class _CheckoutDirty(_PrecheckFailed):
261
+ def __init__(self, paths: tuple[str, ...]) -> None:
262
+ super().__init__(", ".join(paths))
263
+ self.paths = paths
264
+
265
+
266
+ type ContinueRebase = Callable[[], Awaitable[None]]
267
+ type ConflictResolver = Callable[
268
+ [Task, ContinueRebase, ConflictRecovery], Awaitable[tuple[Task, IntegrationMetadata]]
269
+ ]
270
+ type ConflictRecoveryLoader = Callable[[Task], ConflictRecovery]
271
+ type TaskFinisher = Callable[[Task], Awaitable[Path | None]]
272
+
273
+
274
+ @dataclass(frozen=True, slots=True)
275
+ class _OpenPullRequest:
276
+ url: str
277
+ landing: Landing
278
+ base_sha: str | None = None
279
+ branch_sha: str | None = None
280
+ prepared: tuple[tuple[str, str], ...] = ()
281
+
282
+
283
+ class IntegrationStrategy(Protocol):
284
+ """A strategy that applies an already rebased task branch to its base."""
285
+
286
+ def started(self, task_id: str) -> Landing | None:
287
+ """Return what `task_id`'s started, unsettled integration holds, if there is one."""
288
+ ...
289
+
290
+ async def merge(self, request: MergeRequest) -> Landing:
291
+ """Merge the task branch into its base, or start doing so, and return what landed."""
292
+ ...
293
+
294
+ async def applied(self, task_id: str, repo: RepoState, task_root: Path) -> Landing | None:
295
+ """Return what an earlier, interrupted integration of `repo` landed in its base, if any."""
296
+ ...
297
+
298
+ async def settle(
299
+ self, task_id: str, repo: RepoState, held: Callable[[], bool]
300
+ ) -> Landing | None:
301
+ """Wait until `task_id`'s started integration has landed in its base `repo`.
302
+
303
+ Return final landing evidence when settlement obtains it. Raise `IntegrationHeld` instead
304
+ of starting another wait step once `held()` is true.
305
+ """
306
+ ...
307
+
308
+
309
+ class FeedbackIntake(Protocol):
310
+ """Where a waiting pull request learns whether review feedback awaits a round."""
311
+
312
+ async def arrived(self, task_id: str, url: str) -> bool:
313
+ """Return whether `task_id`'s pull request at `url` has feedback awaiting a round."""
314
+ ...
315
+
316
+
317
+ class BasePublication(Protocol):
318
+ """Where a completed local integration registers its base branch for a later push."""
319
+
320
+ def register(self, base: str) -> None:
321
+ """Record an integration of `base` that must reach the remote."""
322
+ ...
323
+
324
+
325
+ class IntegrationCoordinator(Protocol):
326
+ """TaskFlow's required boundary to the integration coordinator."""
327
+
328
+ async def run(
329
+ self,
330
+ task: Task,
331
+ resolve_conflicts: ConflictResolver,
332
+ metadata: IntegrationMetadata,
333
+ conflict_recovery: ConflictRecoveryLoader | None = None,
334
+ ) -> IntegrationSummary:
335
+ """Integrate `task` and finalize it, resolving conflicts through TaskFlow."""
336
+ ...
337
+
338
+ async def continue_rebase(self, task: Task) -> None:
339
+ """Continue `task`'s rebase after its conflicts were resolved, if one is in progress."""
340
+ ...
341
+
342
+ async def prepare_publication(
343
+ self,
344
+ task: Task,
345
+ resolve_conflicts: ConflictResolver,
346
+ metadata: IntegrationMetadata,
347
+ conflict_recovery: ConflictRecoveryLoader,
348
+ ) -> tuple[Task, IntegrationMetadata]:
349
+ """Rebase `task` onto its base before a feedback round publishes."""
350
+ ...
351
+
352
+
353
+ class LocalMerge:
354
+ """Apply a rebased branch locally using fast-forward or a compare-and-swap ref update."""
355
+
356
+ def __init__(
357
+ self,
358
+ git: Git,
359
+ clock: Clock,
360
+ *,
361
+ events: EventLog,
362
+ project_root: Path,
363
+ integration_wait: int,
364
+ ) -> None:
365
+ """Use `git` on `project_root` for repository operations and `clock` for retry deadlines.
366
+
367
+ A wait for a dirty checkout is recorded once in `events` as `integration_waiting`.
368
+ """
369
+ self._git = git
370
+ self._clock = clock
371
+ self._events = events
372
+ self._project_root = project_root
373
+ self._integration_wait = integration_wait
374
+
375
+ def started(self, task_id: str) -> Landing | None:
376
+ """Return `None`: a local merge lands within `merge`, so none is ever left unsettled."""
377
+ del task_id
378
+
379
+ async def applied(self, task_id: str, repo: RepoState, task_root: Path) -> Landing | None:
380
+ """Recover the exact local landing recorded before its ref update, if it applied."""
381
+ events = self._events.since(0, kinds=[EventKind.INTEGRATION_APPLYING], task_id=task_id)
382
+ if not events:
383
+ return None
384
+ data = events[-1].data
385
+ previous_base = data.get(_APPLYING_BASE_SHA_KEY)
386
+ landing_sha = data.get(_APPLYING_LANDING_SHA_KEY)
387
+ if (
388
+ data.get(_APPLYING_BASE_KEY) != repo.base
389
+ or data.get(_APPLYING_BRANCH_KEY) != repo.branch
390
+ or not isinstance(previous_base, str)
391
+ or not isinstance(landing_sha, str)
392
+ ):
393
+ return None
394
+ if not await self._git.merge_base_is_ancestor(
395
+ self._project_root, previous_base, landing_sha
396
+ ):
397
+ return None
398
+ base_sha = await self._git.rev_parse(self._project_root, repo.base)
399
+ if not await self._git.merge_base_is_ancestor(self._project_root, landing_sha, base_sha):
400
+ return None
401
+ task_sha = data.get(_APPLYING_TASK_SHA_KEY)
402
+ if not isinstance(task_sha, str):
403
+ raise IntegrationBlocked(_LOCAL_LANDING_WITHOUT_TASK_TIP)
404
+ current_task_sha = await self._git.rev_parse(task_root, repo.branch)
405
+ if current_task_sha != task_sha:
406
+ raise IntegrationBlocked(_LOCAL_TASK_BRANCH_ADVANCED)
407
+ return _recorded_landing(data)
408
+
409
+ async def merge(self, request: MergeRequest) -> Landing:
410
+ """Pre-check the local base, then apply the merge, retrying while it is blocked."""
411
+ project_root = self._project_root
412
+ repo = request.repo
413
+ deadline = self._clock.now() + timedelta(seconds=self._integration_wait)
414
+ announced = False
415
+ while True:
416
+ try:
417
+ candidate = await self._precheck(project_root, request)
418
+ if candidate is not None:
419
+ if request.held():
420
+ raise IntegrationHeld
421
+ self._record_applying(request, candidate)
422
+ await self._apply(project_root, repo, candidate)
423
+ except (_PrecheckFailed, CommandFailed) as error:
424
+ if isinstance(error, _CheckoutDirty) and not announced:
425
+ announced = True
426
+ self._announce_wait(request.task_id, error.paths)
427
+ if request.held():
428
+ raise IntegrationHeld from None
429
+ remaining = (deadline - self._clock.now()).total_seconds()
430
+ if remaining <= 0:
431
+ raise _blocked_by(request.task_id, error) from error
432
+ await self._clock.sleep(min(INTEGRATION_RETRY.total_seconds(), remaining))
433
+ continue
434
+ break
435
+ return candidate.landing if candidate is not None else self._nothing_landed()
436
+
437
+ def _announce_wait(self, task_id: str, paths: tuple[str, ...]) -> None:
438
+ self._events.append(
439
+ EventKind.INTEGRATION_WAITING,
440
+ task_id=task_id,
441
+ message=f"{task_id} waits to integrate: the project checkout has uncommitted "
442
+ f"changes on {', '.join(paths)}",
443
+ data={"paths": list(paths)},
444
+ )
445
+
446
+ def _record_applying(self, request: MergeRequest, candidate: _MergeCandidate) -> None:
447
+ self._events.append(
448
+ EventKind.INTEGRATION_APPLYING,
449
+ task_id=request.task_id,
450
+ message=f"{request.task_id} integration applying",
451
+ data={
452
+ _APPLYING_BASE_KEY: request.repo.base,
453
+ _APPLYING_BASE_SHA_KEY: candidate.base_sha,
454
+ _APPLYING_BRANCH_KEY: request.repo.branch,
455
+ _APPLYING_TASK_SHA_KEY: candidate.branch_sha,
456
+ _APPLYING_LANDING_SHA_KEY: candidate.landing_sha,
457
+ **candidate.landing.done_data(),
458
+ },
459
+ )
460
+
461
+ async def settle(
462
+ self, task_id: str, repo: RepoState, held: Callable[[], bool]
463
+ ) -> Landing | None:
464
+ """Return at once: `merge` already landed the branch in its base."""
465
+ del task_id, repo, held
466
+ return None
467
+
468
+ async def _apply(self, project_root: Path, repo: RepoState, candidate: _MergeCandidate) -> None:
469
+ if candidate.base_checked_out:
470
+ await self._git.merge_ff_only(project_root, candidate.landing_sha)
471
+ else:
472
+ await self._git.update_ref(
473
+ project_root, repo.base, candidate.landing_sha, candidate.base_sha
474
+ )
475
+
476
+ _integration = MERGE_INTEGRATION
477
+
478
+ def _nothing_landed(self) -> Landing:
479
+ return Landing(integration=self._integration, changes=LandedChanges(0, 0, 0))
480
+
481
+ async def _is_empty(self, project_root: Path, base_sha: str, branch_sha: str) -> bool:
482
+ del project_root
483
+ return branch_sha == base_sha
484
+
485
+ async def _landing_for(
486
+ self, request: MergeRequest, base_sha: str, branch_sha: str
487
+ ) -> tuple[str, Landing]:
488
+ commits = tuple(await self._git.log_oneline(request.task_root, f"{base_sha}..{branch_sha}"))
489
+ changes = await _landed_changes(self._git, request.task_root, base_sha, branch_sha)
490
+ return branch_sha, Landing(commits, integration=self._integration, changes=changes)
491
+
492
+ async def _precheck(self, project_root: Path, request: MergeRequest) -> _MergeCandidate | None:
493
+ task_root_path = request.task_root
494
+ repo = request.repo
495
+ base_sha = await self._git.rev_parse(project_root, repo.base)
496
+ branch_sha = await self._git.rev_parse(task_root_path, repo.branch)
497
+ if await self._is_empty(project_root, base_sha, branch_sha):
498
+ return None
499
+ if not await self._git.merge_base_is_ancestor(project_root, repo.base, repo.branch):
500
+ raise _PrecheckFailed
501
+ base_checked_out = await self._git.current_branch(project_root) == repo.base
502
+ if base_checked_out:
503
+ changed = set(
504
+ await self._git.diff_name_only(task_root_path, f"{base_sha}...{branch_sha}")
505
+ )
506
+ dirty = await self._git.dirty_paths(project_root)
507
+ blocking = _blocking_paths(changed, dirty)
508
+ if blocking:
509
+ raise _CheckoutDirty(blocking)
510
+ landing_sha, landing = await self._landing_for(request, base_sha, branch_sha)
511
+ return _MergeCandidate(
512
+ base_sha=base_sha,
513
+ branch_sha=branch_sha,
514
+ landing_sha=landing_sha,
515
+ base_checked_out=base_checked_out,
516
+ landing=landing,
517
+ )
518
+
519
+
520
+ class SquashMerge(LocalMerge):
521
+ """Land a rebased branch as one commit on its base, sharing every local-merge guard.
522
+
523
+ The commit carries the branch's complete tree on top of the current base and trailers naming
524
+ the task and the branch tip it squashed, which is how an interrupted integration is
525
+ recognised afterwards: the original branch is no longer an ancestor of the base.
526
+ """
527
+
528
+ async def applied(self, task_id: str, repo: RepoState, task_root: Path) -> Landing | None:
529
+ """Return the squash commit already on the base for the branch's current tip, if any."""
530
+ recorded = await super().applied(task_id, repo, task_root)
531
+ if recorded is not None:
532
+ return recorded
533
+ branch_sha = await self._git.rev_parse(task_root, repo.branch)
534
+ origin = await self._git.merge_base(self._project_root, repo.base, branch_sha)
535
+ landed = await self._git.find_commit(
536
+ self._project_root, f"{origin}..{repo.base}", _tip_trailer(branch_sha)
537
+ )
538
+ return await self._landing_of(landed) if landed is not None else None
539
+
540
+ _integration = SQUASH_INTEGRATION
541
+
542
+ async def _is_empty(self, project_root: Path, base_sha: str, branch_sha: str) -> bool:
543
+ base_tree = await self._git.rev_parse(project_root, f"{base_sha}^{{tree}}")
544
+ return base_tree == await self._git.rev_parse(project_root, f"{branch_sha}^{{tree}}")
545
+
546
+ async def _landing_for(
547
+ self, request: MergeRequest, base_sha: str, branch_sha: str
548
+ ) -> tuple[str, Landing]:
549
+ squash_sha = await self._git.commit_tree(
550
+ self._project_root, branch_sha, base_sha, _squash_message(request, branch_sha)
551
+ )
552
+ return squash_sha, await self._landing_of(squash_sha, parent=base_sha)
553
+
554
+ async def _landing_of(self, squash_sha: str, *, parent: str | None = None) -> Landing:
555
+ before = parent if parent is not None else f"{squash_sha}^"
556
+ commits = await self._git.log_oneline(self._project_root, f"{before}..{squash_sha}")
557
+ changes = await _landed_changes(self._git, self._project_root, before, squash_sha)
558
+ return Landing(tuple(commits), integration=SQUASH_INTEGRATION, changes=changes)
559
+
560
+
561
+ async def _landed_changes(git: Git, repo_dir: Path, before: str, after: str) -> LandedChanges:
562
+ stats = await git.committed_line_changes(repo_dir, before, after)
563
+ return LandedChanges(stats.changed_paths, stats.added_lines, stats.deleted_lines)
564
+
565
+
566
+ def _recorded_landing(data: dict[str, object]) -> Landing | None:
567
+ changes = landed_changes(data)
568
+ recorded_commits = data.get(_INTEGRATION_COMMITS_KEY)
569
+ integration = data.get(DONE_INTEGRATION_KEY)
570
+ if (
571
+ changes is None
572
+ or not isinstance(recorded_commits, list)
573
+ or not isinstance(integration, str)
574
+ ):
575
+ return None
576
+ commits: list[str] = []
577
+ for commit in cast("list[object]", recorded_commits):
578
+ if not isinstance(commit, str):
579
+ return None
580
+ commits.append(commit)
581
+ return Landing(tuple(commits), integration, LandedChanges(*changes))
582
+
583
+
584
+ def _tip_trailer(branch_sha: str) -> str:
585
+ return f"{SQUASH_TIP_TRAILER}: {branch_sha}"
586
+
587
+
588
+ def _squash_message(request: MergeRequest, branch_sha: str) -> str:
589
+ subject = f"{request.subject} ({request.task_id})"
590
+ trailers = f"{SQUASH_TASK_TRAILER}: {request.task_id}\n{_tip_trailer(branch_sha)}"
591
+ body = request.summary.strip()
592
+ return "\n\n".join(part for part in (subject, body, trailers) if part)
593
+
594
+
595
+ def _blocking_paths(changed: set[str], dirty: set[str]) -> tuple[str, ...]:
596
+ return tuple(
597
+ sorted(right for right in dirty if any(_overlaps(left, right) for left in changed))
598
+ )
599
+
600
+
601
+ def _overlaps(left: str, right: str) -> bool:
602
+ left, right = left.rstrip("/"), right.rstrip("/")
603
+ return left == right or left.startswith(f"{right}/") or right.startswith(f"{left}/")
604
+
605
+
606
+ class PullRequest:
607
+ """Push a rebased branch, open a pull request for it and poll it until it lands."""
608
+
609
+ def __init__(
610
+ self,
611
+ *,
612
+ git: Git,
613
+ github: GitHub,
614
+ clock: Clock,
615
+ tasks: TaskStore,
616
+ events: EventLog,
617
+ project_root: Path,
618
+ remote: str,
619
+ base_refs: FetchedBaseRefs,
620
+ local_base: LocalBaseSync,
621
+ faults: FaultReporter,
622
+ settings: OrchestrationConfig,
623
+ feedback: FeedbackIntake | None = None,
624
+ ) -> None:
625
+ """Push to `remote` from `project_root`; record the PR in `events`, waits in `tasks`.
626
+
627
+ With `feedback`, an open PR that has review feedback ends its wait with `FeedbackPending`.
628
+
629
+ A merged PR's base is fetched and verified through `base_refs`, and the local base is
630
+ fast-forwarded through `local_base`, before the task is done.
631
+ An open PR that stays unmerged for `timeouts.pr_wait` seconds escalates. A failed poll is
632
+ recorded through `faults` and retried `network.poll_attempts` times on the
633
+ `faults` backoff before it escalates.
634
+ """
635
+ self._git = git
636
+ self._github = github
637
+ self._clock = clock
638
+ self._tasks = tasks
639
+ self._events = events
640
+ self._project_root = project_root
641
+ self._remote = remote
642
+ self._base_refs = base_refs
643
+ self._local_base = local_base
644
+ self._faults = faults
645
+ self._settings = settings
646
+ self._feedback = feedback
647
+
648
+ def started(self, task_id: str) -> Landing | None:
649
+ """Return what `task_id`'s open pull request holds, if it has one."""
650
+ opened = self._open_pull_request(task_id)
651
+ return opened.landing if opened is not None else None
652
+
653
+ async def applied(self, task_id: str, repo: RepoState, task_root: Path) -> Landing | None:
654
+ """Return `None`: only a pull request merged on the remote lands, and `settle` waits."""
655
+ del task_id, repo, task_root
656
+
657
+ async def merge(self, request: MergeRequest) -> Landing:
658
+ """Push the branch and open its pull request, recorded as `pr_opened`.
659
+
660
+ An open pull request of the branch that was never recorded (the engine died between
661
+ opening and recording it) is adopted instead of opening another.
662
+ """
663
+ repo = request.repo
664
+ commits = tuple(
665
+ await self._git.log_oneline(request.task_root, f"{repo.base_ref}..{repo.branch}")
666
+ )
667
+ base_sha = await self._git.rev_parse(request.task_root, repo.base_ref)
668
+ branch_sha = await self._git.rev_parse(request.task_root, repo.branch)
669
+ changes = await _landed_changes(self._git, request.task_root, base_sha, branch_sha)
670
+ if not commits:
671
+ return Landing(integration=PULL_REQUEST_INTEGRATION, changes=changes)
672
+ lease = await self._git.remote_branch_sha(self._project_root, self._remote, repo.branch)
673
+ await self._git.push(self._project_root, self._remote, repo.branch, lease=lease)
674
+ url = await self._github.open_pull_request(
675
+ self._project_root, base=repo.base, head=repo.branch
676
+ ) or await self._github.create_pull_request(
677
+ self._project_root,
678
+ base=repo.base,
679
+ head=repo.branch,
680
+ title=request.title,
681
+ body=request.summary,
682
+ )
683
+ self._events.append(
684
+ EventKind.PR_OPENED,
685
+ task_id=request.task_id,
686
+ message=f"{request.task_id} opened {url}",
687
+ data={
688
+ "url": url,
689
+ "branch": repo.branch,
690
+ _INTEGRATION_COMMITS_KEY: list(commits),
691
+ DONE_CHANGES_KEY: changes.as_data(),
692
+ _PR_BASE_SHA_KEY: base_sha,
693
+ _PR_BRANCH_SHA_KEY: branch_sha,
694
+ },
695
+ )
696
+ return Landing(commits, integration=PULL_REQUEST_INTEGRATION, changes=changes)
697
+
698
+ async def settle(
699
+ self, task_id: str, repo: RepoState, held: Callable[[], bool]
700
+ ) -> Landing | None:
701
+ """Poll the open pull request until it is merged or closed, then sync the base to it.
702
+
703
+ The remote base is verified to hold the merged commit, and the local base is
704
+ fast-forwarded to the remote tip, before this returns evidence read from the landing commit.
705
+ Raises `PullRequestClosed` for a closed one, `PullRequestConflicting` when the open PR
706
+ conflicts with its base, `IntegrationBlocked` when the merged commit cannot be fetched
707
+ from and verified on the remote base, when the local base cannot fast-forward to it
708
+ without losing work, when the open PR has failing, settled checks, or
709
+ when it stays unmerged for `pr_wait` seconds (counted from the start of each wait), and
710
+ `IntegrationHeld` before any poll once `held()` is true, and `FeedbackPending` when review
711
+ feedback awaits a round.
712
+ """
713
+ opened = self._open_pull_request(task_id)
714
+ if opened is None:
715
+ return None
716
+ self._tasks.set_slot(task_id, None)
717
+ self._tasks.set_wait_reason(task_id, WaitReason.PR_OPEN)
718
+ deadline = self._clock.now() + timedelta(seconds=self._settings.timeouts.pr_wait)
719
+ while True:
720
+ if held():
721
+ raise IntegrationHeld
722
+ status = await self._status(task_id, opened.url, held)
723
+ if status.state is not PullRequestState.OPEN:
724
+ break
725
+ if status.conflicting:
726
+ self._tasks.set_wait_reason(task_id, None)
727
+ raise PullRequestConflicting(opened.url)
728
+ stalled = _stalled(status, expired=self._clock.now() >= deadline)
729
+ if stalled is not None:
730
+ self._tasks.set_wait_reason(task_id, None)
731
+ raise PullRequestStalled(opened.url, stalled)
732
+ await self._end_wait_for_feedback(task_id, opened.url)
733
+ await self._clock.sleep(self._settings.network.pr_poll_seconds)
734
+ self._tasks.set_wait_reason(task_id, None)
735
+ if status.state is PullRequestState.CLOSED:
736
+ raise PullRequestClosed(opened.url)
737
+ merge_commit = status.merge_commit
738
+ if merge_commit is None:
739
+ raise IntegrationBlocked(_MERGED_PR_WITHOUT_COMMIT)
740
+ base_sha = opened.base_sha or await self._git.rev_parse(self._project_root, repo.base_ref)
741
+ try:
742
+ tip = await self._base_refs.sync(repo.base, merge_commit)
743
+ await self._local_base.advance(repo.base, tip)
744
+ except (BaseFetchFailed, LocalBaseBlocked, MergedBaseUnavailable) as error:
745
+ raise IntegrationBlocked(str(error)) from error
746
+ return await self._landed_with_prepared(opened, merge_commit, base_sha)
747
+
748
+ async def _landed_with_prepared(
749
+ self, opened: _OpenPullRequest, merge_commit: str, base_sha: str
750
+ ) -> Landing:
751
+ for tip, base in reversed(opened.prepared):
752
+ if await self._git.merge_base_is_ancestor(self._project_root, tip, merge_commit):
753
+ opened = replace(opened, branch_sha=tip)
754
+ base_sha = base
755
+ break
756
+ return await self._landed_pull_request(opened, merge_commit, base_sha)
757
+
758
+ async def _end_wait_for_feedback(self, task_id: str, url: str) -> None:
759
+ if self._feedback is not None and await self._feedback.arrived(task_id, url):
760
+ self._tasks.set_wait_reason(task_id, None)
761
+ raise FeedbackPending
762
+
763
+ async def _landed_pull_request(
764
+ self, opened: _OpenPullRequest, merge_commit: str, base_sha: str
765
+ ) -> Landing:
766
+ parents = list(await self._git.commit_parents(self._project_root, merge_commit))
767
+ if not parents:
768
+ raise IntegrationBlocked(_MERGED_PR_COMMIT_WITHOUT_PARENT)
769
+ if len(parents) > 1:
770
+ before = parents[0]
771
+ elif opened.branch_sha is not None and await self._git.merge_base_is_ancestor(
772
+ self._project_root, opened.branch_sha, merge_commit
773
+ ):
774
+ before = base_sha
775
+ else:
776
+ before = parents[0]
777
+ commits = tuple(
778
+ await self._git.log_oneline(self._project_root, f"{before}..{merge_commit}")
779
+ )
780
+ changes = await _landed_changes(self._git, self._project_root, before, merge_commit)
781
+ return Landing(commits, integration=PULL_REQUEST_INTEGRATION, changes=changes)
782
+
783
+ async def _status(self, task_id: str, url: str, held: Callable[[], bool]) -> PullRequestStatus:
784
+ attempts = self._settings.network.poll_attempts
785
+ backoff = self._settings.faults
786
+ delay = 0.0
787
+ attempt = 0
788
+ while True:
789
+ attempt += 1
790
+ try:
791
+ status = await self._github.pull_request_status(self._project_root, url)
792
+ except (CommandFailed, CommandTimeoutError) as error:
793
+ self._faults.report(error, scope=POLL_FAULT_SCOPE, task_id=task_id)
794
+ if attempt >= attempts:
795
+ raise
796
+ delay = next_backoff(
797
+ delay,
798
+ initial=backoff.backoff_initial_seconds,
799
+ maximum=backoff.backoff_max_seconds,
800
+ )
801
+ await self._clock.sleep(delay)
802
+ if held():
803
+ raise IntegrationHeld from None
804
+ else:
805
+ self._faults.resolve(POLL_FAULT_SCOPE, task_id=task_id)
806
+ return status
807
+
808
+ def _open_pull_request(self, task_id: str) -> _OpenPullRequest | None:
809
+ return _open_pull_request(self._events, task_id, _INTEGRATION_RESTARTED_BY)
810
+
811
+
812
+ def _stalled(status: PullRequestStatus, *, expired: bool) -> str | None:
813
+ if status.checks_failing and not status.checks_pending:
814
+ return "failing checks"
815
+ return "unmerged beyond the wait budget" if expired else None
816
+
817
+
818
+ def _open_pull_request(
819
+ events: EventLog, task_id: str, ended_by: frozenset[EscalationReason]
820
+ ) -> _OpenPullRequest | None:
821
+ opened: _OpenPullRequest | None = None
822
+ kinds = [
823
+ EventKind.PR_OPENED,
824
+ EventKind.FEEDBACK_PUBLISHED,
825
+ EventKind.FEEDBACK_PREPARED,
826
+ EventKind.PR_WITHDRAWN,
827
+ EventKind.ESCALATION,
828
+ ]
829
+ for event in events.since(0, kinds=kinds, task_id=task_id):
830
+ if event.kind is EventKind.PR_OPENED:
831
+ found = landed_changes(event.data)
832
+ opened = _OpenPullRequest(
833
+ event.data["url"],
834
+ Landing(
835
+ tuple(event.data[_INTEGRATION_COMMITS_KEY]),
836
+ integration=PULL_REQUEST_INTEGRATION if found is not None else None,
837
+ changes=LandedChanges(*found) if found is not None else None,
838
+ ),
839
+ event.data.get(_PR_BASE_SHA_KEY),
840
+ event.data.get(_PR_BRANCH_SHA_KEY),
841
+ )
842
+ elif event.kind is EventKind.FEEDBACK_PREPARED:
843
+ if opened is not None and event.data["url"] == opened.url:
844
+ opened = replace(
845
+ opened,
846
+ prepared=(
847
+ *opened.prepared,
848
+ (event.data[PUBLISHED_TIP_KEY], event.data[PUBLISHED_BASE_KEY]),
849
+ ),
850
+ )
851
+ elif event.kind is EventKind.FEEDBACK_PUBLISHED:
852
+ if opened is not None and event.data["url"] == opened.url:
853
+ opened = replace(
854
+ opened,
855
+ base_sha=event.data[PUBLISHED_BASE_KEY],
856
+ branch_sha=event.data[PUBLISHED_TIP_KEY],
857
+ )
858
+ elif event.kind is EventKind.PR_WITHDRAWN or event.data["reason"] in ended_by:
859
+ opened = None
860
+ return opened
861
+
862
+
863
+ class PullRequestCloser:
864
+ """Close a task's open pull request when the task is cancelled."""
865
+
866
+ def __init__(self, *, github: GitHub, events: EventLog, project_root: Path) -> None:
867
+ """Close through `github` from `project_root` the PR that `events` record as open."""
868
+ self._github = github
869
+ self._events = events
870
+ self._project_root = project_root
871
+
872
+ def has_open(self, task_id: str) -> bool:
873
+ """Return whether `task_id` has a pull request that was neither closed nor withdrawn."""
874
+ return _open_pull_request(self._events, task_id, _PR_ENDED_BY) is not None
875
+
876
+ async def close_open(self, task_id: str) -> None:
877
+ """Close `task_id`'s open pull request, if it has one that is still open.
878
+
879
+ Records `pr_withdrawn` once the pull request is closed or was found not open, so the
880
+ close is never repeated.
881
+ """
882
+ opened = _open_pull_request(self._events, task_id, _PR_ENDED_BY)
883
+ if opened is None:
884
+ return
885
+ state = (await self._github.pull_request_status(self._project_root, opened.url)).state
886
+ if state is PullRequestState.OPEN:
887
+ await self._github.close_pull_request(self._project_root, opened.url)
888
+ state = PullRequestState.CLOSED
889
+ self._events.append(
890
+ EventKind.PR_WITHDRAWN,
891
+ task_id=task_id,
892
+ message=f"{task_id} withdrew {opened.url}",
893
+ data={"url": opened.url, "state": state.value},
894
+ )
895
+
896
+
897
+ class Integrator:
898
+ """Own the project integration lock, rebase protocol and terminal cleanup."""
899
+
900
+ def __init__(
901
+ self,
902
+ *,
903
+ git: Git,
904
+ strategy: IntegrationStrategy,
905
+ base_refs: BaseRefs,
906
+ lock: asyncio.Lock,
907
+ project_root: Path,
908
+ tasks: TaskStore,
909
+ events: EventLog,
910
+ finish_task: TaskFinisher,
911
+ publication: BasePublication | None = None,
912
+ atomically: Callable[[], AbstractContextManager[object]] = nullcontext,
913
+ ) -> None:
914
+ """Inject every state, cleanup and repository boundary required by integration.
915
+
916
+ The rebase starts from the ref `base_refs` resolves, so a remote base is fetched afresh.
917
+ A completed integration registers its base with `publication` inside the same
918
+ `atomically` transaction that records the task as done, so a crash between the Git
919
+ update and that record is repaired by the integration running again.
920
+ """
921
+ self._git = git
922
+ self._strategy = strategy
923
+ self._base_refs = base_refs
924
+ self._lock = lock
925
+ self._project_root = project_root
926
+ self._tasks = tasks
927
+ self._events = events
928
+ self._finish_task = finish_task
929
+ self._publication = publication
930
+ self._atomically = atomically
931
+
932
+ async def run(
933
+ self,
934
+ task: Task,
935
+ resolve_conflicts: ConflictResolver,
936
+ metadata: IntegrationMetadata,
937
+ conflict_recovery: ConflictRecoveryLoader | None = None,
938
+ ) -> IntegrationSummary:
939
+ """Rebase and integrate `task`, delegating conflict turns to TaskFlow.
940
+
941
+ An integration the strategy already started (an open PR) is only settled, never
942
+ rebased or applied again.
943
+ """
944
+ repo = task.repo
945
+ if repo is None:
946
+ message = f"{task.id} has no task root to integrate"
947
+ raise InvariantError(message)
948
+ current, current_metadata = task, metadata
949
+ started = self._strategy.started(task.id)
950
+ if started is None:
951
+ current, current_metadata, landing = await self._apply(
952
+ task, repo, resolve_conflicts, metadata, conflict_recovery
953
+ )
954
+ else:
955
+ landing = started
956
+ settled = await self._strategy.settle(current.id, repo, lambda: self._is_held(current.id))
957
+ if settled is not None:
958
+ landing = settled
959
+ summary = IntegrationSummary(
960
+ summary=current_metadata.summary,
961
+ commits=landing.commits,
962
+ residual_risks=current_metadata.residual_risks,
963
+ )
964
+ self._tasks.set_summary(current.id, summary.summary)
965
+ updated = self._tasks.get(current.id)
966
+ if updated is None:
967
+ message = f"{current.id} vanished before integration could finish"
968
+ raise InvariantError(message)
969
+ with self._atomically():
970
+ self._tasks.finish(
971
+ current.id,
972
+ _done_transition(updated),
973
+ EventKind.TASK_DONE,
974
+ message=summary.summary,
975
+ data={
976
+ "summary": summary.summary,
977
+ **landing.done_data(),
978
+ "residual_risks": list(summary.residual_risks),
979
+ },
980
+ )
981
+ if self._publication is not None:
982
+ self._publication.register(repo.base)
983
+ done = self._tasks.get(current.id)
984
+ if done is None:
985
+ message = f"{current.id} vanished after integration completed"
986
+ raise InvariantError(message)
987
+ await self._finish_task(done)
988
+ return summary
989
+
990
+ async def _apply(
991
+ self,
992
+ task: Task,
993
+ repo: RepoState,
994
+ resolve_conflicts: ConflictResolver,
995
+ metadata: IntegrationMetadata,
996
+ conflict_recovery: ConflictRecoveryLoader | None,
997
+ ) -> tuple[Task, IntegrationMetadata, Landing]:
998
+ async with self._lock:
999
+ self._require_not_held(task.id)
1000
+ task_dir = task_root(self._project_root, task.id)
1001
+ if not await self._git.is_rebase_in_progress(task_dir):
1002
+ await self._require_integrable(task, repo, task_dir)
1003
+ landed = await self._strategy.applied(task.id, repo, task_dir)
1004
+ if landed is not None:
1005
+ return task, metadata, landed
1006
+ recovery_state = (
1007
+ conflict_recovery(task) if conflict_recovery is not None else ConflictRecovery()
1008
+ )
1009
+ current = task
1010
+ current_metadata = metadata
1011
+ in_progress = await self._git.is_rebase_in_progress(task_dir)
1012
+ if not in_progress and not recovery_state.resumed_report:
1013
+ self._events.append(
1014
+ EventKind.INTEGRATION_STARTED,
1015
+ task_id=task.id,
1016
+ message=f"{task.id} integration started",
1017
+ data={"branch": repo.branch, "base": repo.base},
1018
+ )
1019
+ recovery_state = ConflictRecovery(force_new_turn=recovery_state.force_new_turn)
1020
+ current, current_metadata = await self._rebase(
1021
+ current,
1022
+ repo,
1023
+ task_dir,
1024
+ resolve_conflicts=resolve_conflicts,
1025
+ metadata=current_metadata,
1026
+ recovery_state=recovery_state,
1027
+ )
1028
+ self._require_not_held(current.id)
1029
+ landing = await self._strategy.merge(
1030
+ MergeRequest(
1031
+ task_id=current.id,
1032
+ title=current.title,
1033
+ subject=current_metadata.subject,
1034
+ repo=repo,
1035
+ task_root=task_dir,
1036
+ summary=current_metadata.summary,
1037
+ held=lambda: self._is_held(current.id),
1038
+ )
1039
+ )
1040
+ return current, current_metadata, landing
1041
+
1042
+ async def prepare_publication(
1043
+ self,
1044
+ task: Task,
1045
+ resolve_conflicts: ConflictResolver,
1046
+ metadata: IntegrationMetadata,
1047
+ conflict_recovery: ConflictRecoveryLoader,
1048
+ ) -> tuple[Task, IntegrationMetadata]:
1049
+ """Rebase `task` onto its base before a feedback round publishes, resolving conflicts.
1050
+
1051
+ Runs under the integration lock and records no integration start.
1052
+ """
1053
+ repo = task.repo
1054
+ if repo is None:
1055
+ message = f"{task.id} has no task root to publish"
1056
+ raise InvariantError(message)
1057
+ async with self._lock:
1058
+ self._require_not_held(task.id)
1059
+ task_dir = task_root(self._project_root, task.id)
1060
+ recovery = conflict_recovery(task)
1061
+ if not await self._git.is_rebase_in_progress(task_dir) and not recovery.resumed_report:
1062
+ self._events.append(
1063
+ EventKind.FEEDBACK_REBASE_STARTED,
1064
+ task_id=task.id,
1065
+ message=f"{task.id} feedback rebase started",
1066
+ data={"branch": repo.branch, "base": repo.base},
1067
+ )
1068
+ recovery = ConflictRecovery(force_new_turn=recovery.force_new_turn)
1069
+ current, current_metadata = await self._rebase(
1070
+ task,
1071
+ repo,
1072
+ task_dir,
1073
+ resolve_conflicts=resolve_conflicts,
1074
+ metadata=metadata,
1075
+ recovery_state=recovery,
1076
+ )
1077
+ self._require_not_held(current.id)
1078
+ return current, current_metadata
1079
+
1080
+ async def _rebase(
1081
+ self,
1082
+ task: Task,
1083
+ repo: RepoState,
1084
+ task_dir: Path,
1085
+ *,
1086
+ resolve_conflicts: ConflictResolver,
1087
+ metadata: IntegrationMetadata,
1088
+ recovery_state: ConflictRecovery,
1089
+ ) -> tuple[Task, IntegrationMetadata]:
1090
+ current = task
1091
+ current_metadata = metadata
1092
+ in_progress = await self._git.is_rebase_in_progress(task_dir)
1093
+ await self._require_integrable(current, repo, task_dir)
1094
+ base_ref = await self._resolve_base(repo)
1095
+ base_sha = await self._git.rev_parse(task_dir, base_ref)
1096
+ branch_sha = await self._git.rev_parse(task_dir, repo.branch)
1097
+ recovery = None
1098
+ if in_progress:
1099
+ recovery = await self._try_rebase(
1100
+ task_dir,
1101
+ base_ref,
1102
+ current,
1103
+ recovery_state,
1104
+ )
1105
+ elif recovery_state.resumed_report and (
1106
+ base_sha == branch_sha
1107
+ or await self._git.merge_base_is_ancestor(task_dir, base_ref, repo.branch)
1108
+ ):
1109
+ recovery = recovery_state
1110
+ elif base_sha != branch_sha:
1111
+ recovery = await self._try_rebase(task_dir, base_ref, current, recovery_state)
1112
+ if recovery is None and recovery_state.resumed_report:
1113
+ recovery = recovery_state
1114
+ if recovery is not None:
1115
+ current, current_metadata = await resolve_conflicts(
1116
+ current,
1117
+ lambda: self._continue_rebase(current.id, task_dir),
1118
+ recovery,
1119
+ )
1120
+ await self._continue_rebase(current.id, task_dir)
1121
+ await self._require_integrable(current, repo, task_dir)
1122
+ return current, current_metadata
1123
+
1124
+ async def _resolve_base(self, repo: RepoState) -> str:
1125
+ try:
1126
+ return await self._base_refs.resolve(repo.base)
1127
+ except BaseFetchFailed as error:
1128
+ raise IntegrationBlocked(str(error)) from error
1129
+
1130
+ def _is_held(self, task_id: str) -> bool:
1131
+ current = self._tasks.get(task_id)
1132
+ if current is None:
1133
+ message = f"{task_id} vanished during integration"
1134
+ raise InvariantError(message)
1135
+ return current.held
1136
+
1137
+ def _require_not_held(self, task_id: str) -> None:
1138
+ if self._is_held(task_id):
1139
+ raise IntegrationHeld
1140
+
1141
+ async def _require_integrable(self, task: Task, repo: RepoState, task_dir: Path) -> None:
1142
+ if await self._git.is_rebase_in_progress(task_dir):
1143
+ return
1144
+ head_branch = await self._git.head_branch(task_dir)
1145
+ if head_branch != repo.branch:
1146
+ commits = await self._git.log_oneline(task_dir, f"{repo.base_ref}..HEAD")
1147
+ raise OffTaskBranch(task.id, repo.branch, head_branch, commits)
1148
+ status = await self._git.status_porcelain(task_dir)
1149
+ if status:
1150
+ raise UncommittedWork(task.id, status)
1151
+
1152
+ async def continue_rebase(self, task: Task) -> None:
1153
+ """Continue `task`'s rebase after its conflicts were resolved, if one is in progress."""
1154
+ await self._continue_rebase(task.id, task_root(self._project_root, task.id))
1155
+
1156
+ async def _try_rebase(
1157
+ self,
1158
+ task_dir: Path,
1159
+ base: str,
1160
+ task: Task,
1161
+ recovery_state: ConflictRecovery,
1162
+ ) -> ConflictRecovery | None:
1163
+ if await self._git.is_rebase_in_progress(task_dir):
1164
+ paths = await self._git.unmerged_paths(task_dir)
1165
+ if paths:
1166
+ return dataclasses.replace(recovery_state, resumed_report=False)
1167
+ if recovery_state.resumed_report:
1168
+ return recovery_state
1169
+ await self._git.rebase_abort(task_dir)
1170
+ try:
1171
+ await self._git.rebase(task_dir, base)
1172
+ except CommandTimeoutError, asyncio.CancelledError:
1173
+ await self._abort_interrupted_rebase(task_dir)
1174
+ raise
1175
+ except CommandFailed as error:
1176
+ paths = await self._git.unmerged_paths(task_dir)
1177
+ if paths:
1178
+ return dataclasses.replace(recovery_state, resumed_report=False)
1179
+ raise IntegrationBlocked(_git_failure_message(task.id, "rebase", error)) from error
1180
+ return None
1181
+
1182
+ async def _abort_interrupted_rebase(self, task_dir: Path) -> None:
1183
+ abort = asyncio.create_task(self._abort_rebase(task_dir))
1184
+ try:
1185
+ await asyncio.shield(abort)
1186
+ except asyncio.CancelledError:
1187
+ await abort
1188
+ raise
1189
+
1190
+ async def _abort_rebase(self, task_dir: Path) -> None:
1191
+ if await self._git.is_rebase_in_progress(task_dir):
1192
+ await self._git.rebase_abort(task_dir)
1193
+
1194
+ async def _continue_rebase(self, task_id: str, task_dir: Path) -> None:
1195
+ if not await self._git.is_rebase_in_progress(task_dir):
1196
+ return
1197
+ paths = await self._git.unmerged_paths(task_dir)
1198
+ if paths:
1199
+ raise RebaseConflict(paths)
1200
+ self._require_not_held(task_id)
1201
+ try:
1202
+ await self._git.rebase_continue(task_dir)
1203
+ except CommandFailed as error:
1204
+ paths = await self._git.unmerged_paths(task_dir)
1205
+ if paths:
1206
+ raise RebaseConflict(paths) from error
1207
+ raise IntegrationBlocked(
1208
+ _git_failure_message(task_id, "rebase --continue", error)
1209
+ ) from error
1210
+
1211
+
1212
+ def _git_failure_message(task_id: str, command: str, error: CommandFailed) -> str:
1213
+ detail = error.stderr.strip()
1214
+ if len(detail) > GIT_FAILURE_DETAIL_LIMIT:
1215
+ detail = f"{detail[:GIT_FAILURE_DETAIL_LIMIT]}{GIT_FAILURE_TRUNCATION}"
1216
+ if not detail:
1217
+ detail = "Git returned no error details."
1218
+ return f"{task_id}: git {command} failed with exit {error.returncode}: {detail}"
1219
+
1220
+
1221
+ def _blocked_by(task_id: str, blocker: _PrecheckFailed | CommandFailed) -> IntegrationBlocked:
1222
+ if isinstance(blocker, _CheckoutDirty):
1223
+ return IntegrationBlocked(
1224
+ f"{task_id}: the project checkout has uncommitted changes on {', '.join(blocker.paths)}"
1225
+ )
1226
+ if isinstance(blocker, CommandFailed):
1227
+ return IntegrationBlocked(
1228
+ _git_failure_message(task_id, " ".join(blocker.argv[1:]), blocker)
1229
+ )
1230
+ return IntegrationBlocked()
1231
+
1232
+
1233
+ def _done_transition(task: Task) -> Transition:
1234
+ return Transition.stay(task, step=None, status=Status.DONE)