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
shikiru/store/tasks.py ADDED
@@ -0,0 +1,949 @@
1
+ """Task rows: insert, transitions, holds and dependent repointing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, cast
7
+
8
+ from pydantic import TypeAdapter
9
+
10
+ from shikiru.agents.contracts import Rung
11
+ from shikiru.domain.escalation import EscalationStatus
12
+ from shikiru.domain.events import EventKind
13
+ from shikiru.domain.reports import SIBLING_REFERENCE_PREFIX, resolve_dependency
14
+ from shikiru.domain.scheduling import find_dependency_cycle
15
+ from shikiru.domain.tasks import (
16
+ ACTIVE_STATUSES,
17
+ DEFAULT_COMPLEXITY,
18
+ Counters,
19
+ Hold,
20
+ Mode,
21
+ RepoState,
22
+ Role,
23
+ Status,
24
+ StatusReason,
25
+ Step,
26
+ Task,
27
+ TaskOrigin,
28
+ )
29
+ from shikiru.errors import InvariantError, UsageError
30
+ from shikiru.store.database import (
31
+ dump_json,
32
+ format_timestamp,
33
+ load_json,
34
+ next_sequence,
35
+ parse_required_timestamp,
36
+ parse_timestamp,
37
+ require_after_insert,
38
+ transaction,
39
+ )
40
+ from shikiru.store.events import EventLog, fetch_latest_events
41
+
42
+ if TYPE_CHECKING:
43
+ import sqlite3 # noqa: TID251
44
+ from collections.abc import Sequence
45
+
46
+ from shikiru.clock import Clock
47
+ from shikiru.config.settings import ComplexityName
48
+ from shikiru.domain.commands import Submit
49
+ from shikiru.domain.reports import NewTask
50
+ from shikiru.domain.scheduling import WaitReason
51
+ from shikiru.domain.steps import Transition
52
+
53
+ TASK_ID_PREFIX = "T-"
54
+ TASK_SEQUENCE = "task"
55
+
56
+ _HOLDS_ADAPTER: TypeAdapter[frozenset[Hold]] = TypeAdapter(frozenset[Hold])
57
+ _COUNTERS_ADAPTER: TypeAdapter[Counters] = TypeAdapter(Counters)
58
+ _RUNG_INDEX_ADAPTER: TypeAdapter[dict[Role, int]] = TypeAdapter(dict[Role, int])
59
+ _OVERRIDES_ADAPTER: TypeAdapter[dict[Role, Rung]] = TypeAdapter(dict[Role, Rung])
60
+ _PANES_ADAPTER: TypeAdapter[dict[Role, str]] = TypeAdapter(dict[Role, str])
61
+ _REPO_ADAPTER: TypeAdapter[RepoState] = TypeAdapter(RepoState)
62
+ _BROWSER_CATEGORY_ACTIVE = "active"
63
+ _BROWSER_CATEGORY_WAITING = "waiting"
64
+ _BROWSER_CATEGORY_HISTORY = "history"
65
+ _BROWSER_CATEGORY_ALL = "all"
66
+ _BROWSER_STATUS_FILTERS = frozenset(status.value for status in Status)
67
+ _BROWSER_FILTERS = _BROWSER_STATUS_FILTERS | {
68
+ _BROWSER_CATEGORY_ACTIVE,
69
+ _BROWSER_CATEGORY_WAITING,
70
+ _BROWSER_CATEGORY_HISTORY,
71
+ _BROWSER_CATEGORY_ALL,
72
+ }
73
+ _RELATIONSHIP_LIMIT = 24
74
+ _FAMILY_OUTCOME_ORDER = (
75
+ Status.QUEUED.value,
76
+ Status.RUNNING.value,
77
+ Status.INTEGRATING.value,
78
+ Status.ESCALATED.value,
79
+ Status.DONE.value,
80
+ Status.CANCELLED.value,
81
+ StatusReason.SUPERSEDED.value,
82
+ )
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class TaskBrowserPage:
87
+ """A bounded task page with counts for the chosen filter and current project."""
88
+
89
+ tasks: tuple[Task, ...]
90
+ open_decision_task_ids: frozenset[str]
91
+ matching_count: int
92
+ filtered_count: int
93
+ project_count: int
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class TaskRelationships:
98
+ """A bounded task family group, separate dependency edges and recorded replacements."""
99
+
100
+ family_parent: Task
101
+ family_root: Task
102
+ family_children: tuple[Task, ...]
103
+ family_child_count: int
104
+ dependencies: tuple[Task, ...]
105
+ dependency_count: int
106
+ blocking_dependencies: tuple[Task, ...]
107
+ blocking_dependency_count: int
108
+ dependents: tuple[Task, ...]
109
+ dependent_count: int
110
+ replacements: tuple[Task, ...]
111
+ replacement_count: int
112
+ family_outcomes: tuple[tuple[str, int], ...]
113
+ page_offset: int
114
+ page_size: int
115
+ has_more: bool
116
+
117
+
118
+ _TASK_COLUMNS = (
119
+ "id, seq, parent_id, origin, title, request, mode, complexity, complexity_explicit, "
120
+ "priority, status, step, "
121
+ "status_reason, holds, iteration, counters, rung_index, overrides, repo, slot, window_id, "
122
+ "panes, wait_reason, requeued_at, summary, created_at, started_at, finished_at"
123
+ )
124
+
125
+
126
+ def _row_to_task(row: sqlite3.Row, depends_on: Sequence[str]) -> Task:
127
+ return Task(
128
+ id=row["id"],
129
+ seq=row["seq"],
130
+ parent_id=row["parent_id"],
131
+ origin=TaskOrigin(row["origin"]),
132
+ title=row["title"],
133
+ request=row["request"],
134
+ mode=Mode(row["mode"]) if row["mode"] is not None else None,
135
+ complexity=row["complexity"] if row["complexity_explicit"] else None,
136
+ priority=row["priority"],
137
+ depends_on=list(depends_on),
138
+ status=Status(row["status"]),
139
+ step=Step(row["step"]) if row["step"] is not None else None,
140
+ status_reason=row["status_reason"],
141
+ holds=load_json(_HOLDS_ADAPTER, row["holds"]),
142
+ iteration=row["iteration"],
143
+ counters=load_json(_COUNTERS_ADAPTER, row["counters"]),
144
+ rung_index=load_json(_RUNG_INDEX_ADAPTER, row["rung_index"]),
145
+ overrides=load_json(_OVERRIDES_ADAPTER, row["overrides"]),
146
+ repo=load_json(_REPO_ADAPTER, row["repo"]) if row["repo"] is not None else None,
147
+ slot=row["slot"],
148
+ window_id=row["window_id"],
149
+ panes=load_json(_PANES_ADAPTER, row["panes"]),
150
+ wait_reason=row["wait_reason"],
151
+ requeued_at=parse_timestamp(row["requeued_at"]),
152
+ summary=row["summary"],
153
+ created_at=parse_required_timestamp(row["created_at"]),
154
+ started_at=parse_timestamp(row["started_at"]),
155
+ finished_at=parse_timestamp(row["finished_at"]),
156
+ )
157
+
158
+
159
+ def _fetch_depends_on(
160
+ connection: sqlite3.Connection, task_ids: Sequence[str]
161
+ ) -> dict[str, list[str]]:
162
+ deps: dict[str, list[str]] = {task_id: [] for task_id in task_ids}
163
+ if not task_ids:
164
+ return deps
165
+ placeholders = ", ".join("?" for _ in task_ids)
166
+ rows = connection.execute(
167
+ f"SELECT task_id, depends_on FROM task_deps WHERE task_id IN ({placeholders})",
168
+ tuple(task_ids),
169
+ ).fetchall()
170
+ for row in rows:
171
+ deps[row["task_id"]].append(row["depends_on"])
172
+ return deps
173
+
174
+
175
+ def _rows_to_tasks(connection: sqlite3.Connection, rows: Sequence[sqlite3.Row]) -> list[Task]:
176
+ deps = _fetch_depends_on(connection, [row["id"] for row in rows])
177
+ return [_row_to_task(row, deps[row["id"]]) for row in rows]
178
+
179
+
180
+ def fetch_task(connection: sqlite3.Connection, task_id: str) -> Task | None:
181
+ """Read one task by id, or `None` if it doesn't exist."""
182
+ row = connection.execute(
183
+ f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id = ?", (task_id,)
184
+ ).fetchone()
185
+ if row is None:
186
+ return None
187
+ return _row_to_task(row, _fetch_depends_on(connection, [task_id])[task_id])
188
+
189
+
190
+ def fetch_tasks(
191
+ connection: sqlite3.Connection, *, statuses: Sequence[Status] | None = None
192
+ ) -> list[Task]:
193
+ """Read every task, optionally restricted to `statuses`, ordered by `seq`."""
194
+ if statuses is None:
195
+ rows = connection.execute(f"SELECT {_TASK_COLUMNS} FROM tasks ORDER BY seq").fetchall()
196
+ else:
197
+ placeholders = ", ".join("?" for _ in statuses)
198
+ rows = connection.execute(
199
+ f"SELECT {_TASK_COLUMNS} FROM tasks WHERE status IN ({placeholders}) ORDER BY seq",
200
+ tuple(status.value for status in statuses),
201
+ ).fetchall()
202
+ return _rows_to_tasks(connection, rows)
203
+
204
+
205
+ def fetch_task_browser_page(
206
+ connection: sqlite3.Connection,
207
+ *,
208
+ category: str,
209
+ search: str,
210
+ limit: int,
211
+ offset: int,
212
+ diagnosed_stall_task_ids: Sequence[str] = (),
213
+ quota_wait_task_ids: Sequence[str] = (),
214
+ ) -> TaskBrowserPage:
215
+ """Read one ordered browser page and exact counts without loading the full task table."""
216
+ if category not in _BROWSER_FILTERS:
217
+ message = f"unknown task browser filter: {category!r}"
218
+ raise ValueError(message)
219
+ if limit < 1 or offset < 0:
220
+ message = "task browser limit must be positive and offset must be non-negative"
221
+ raise ValueError(message)
222
+
223
+ category_where, category_parameters = _task_browser_category(category, quota_wait_task_ids)
224
+ match_where, match_parameters = _task_browser_search(category_where, search)
225
+ counts = connection.execute(
226
+ "SELECT "
227
+ f"(SELECT COUNT(*) FROM tasks t WHERE {category_where}) AS filtered_count, "
228
+ f"(SELECT COUNT(*) FROM tasks t WHERE {match_where}) AS matching_count, "
229
+ "(SELECT COUNT(*) FROM tasks) AS project_count",
230
+ (*category_parameters, *category_parameters, *match_parameters),
231
+ ).fetchone()
232
+ order, order_parameters = _task_browser_order(category, diagnosed_stall_task_ids)
233
+ rows = connection.execute(
234
+ f"SELECT {_TASK_COLUMNS}, "
235
+ "EXISTS (SELECT 1 FROM escalations e WHERE e.task_id = t.id AND e.status = ?) "
236
+ f"AS has_open_decision FROM tasks t WHERE {match_where} "
237
+ f"ORDER BY {order} LIMIT ? OFFSET ?",
238
+ (
239
+ EscalationStatus.OPEN.value,
240
+ *category_parameters,
241
+ *match_parameters,
242
+ *order_parameters,
243
+ limit,
244
+ offset,
245
+ ),
246
+ ).fetchall()
247
+ tasks = _rows_to_tasks(connection, rows)
248
+ return TaskBrowserPage(
249
+ tasks=tuple(tasks),
250
+ open_decision_task_ids=frozenset(row["id"] for row in rows if row["has_open_decision"]),
251
+ matching_count=counts["matching_count"],
252
+ filtered_count=counts["filtered_count"],
253
+ project_count=counts["project_count"],
254
+ )
255
+
256
+
257
+ def _fetch_tasks_by_ids(
258
+ connection: sqlite3.Connection, task_ids: Sequence[str], *, offset: int
259
+ ) -> tuple[Task, ...]:
260
+ page_ids = task_ids[offset : offset + _RELATIONSHIP_LIMIT]
261
+ if not page_ids:
262
+ return ()
263
+ placeholders = ", ".join("?" for _ in page_ids)
264
+ rows = connection.execute(
265
+ f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id IN ({placeholders}) ORDER BY seq",
266
+ tuple(page_ids),
267
+ ).fetchall()
268
+ return tuple(_rows_to_tasks(connection, rows))
269
+
270
+
271
+ def _fetch_relationship_tasks(
272
+ connection: sqlite3.Connection,
273
+ *,
274
+ column: str,
275
+ task_id: str,
276
+ offset: int,
277
+ ) -> tuple[tuple[Task, ...], int]:
278
+ rows = connection.execute(
279
+ f"SELECT {_TASK_COLUMNS} FROM tasks WHERE {column} = ? ORDER BY seq LIMIT ? OFFSET ?",
280
+ (task_id, _RELATIONSHIP_LIMIT, offset),
281
+ ).fetchall()
282
+ count = connection.execute(
283
+ f"SELECT COUNT(*) FROM tasks WHERE {column} = ?", (task_id,)
284
+ ).fetchone()[0]
285
+ return tuple(_rows_to_tasks(connection, rows)), count
286
+
287
+
288
+ def _fetch_dependency_tasks(
289
+ connection: sqlite3.Connection, task_id: str, *, offset: int
290
+ ) -> tuple[tuple[Task, ...], int]:
291
+ rows = connection.execute(
292
+ f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id IN "
293
+ "(SELECT depends_on FROM task_deps WHERE task_id = ?) ORDER BY seq LIMIT ? OFFSET ?",
294
+ (task_id, _RELATIONSHIP_LIMIT, offset),
295
+ ).fetchall()
296
+ count = connection.execute(
297
+ "SELECT COUNT(*) FROM task_deps WHERE task_id = ?", (task_id,)
298
+ ).fetchone()[0]
299
+ return tuple(_rows_to_tasks(connection, rows)), count
300
+
301
+
302
+ def _fetch_blocking_dependency_tasks(
303
+ connection: sqlite3.Connection, task_id: str, *, offset: int
304
+ ) -> tuple[tuple[Task, ...], int]:
305
+ rows = connection.execute(
306
+ f"SELECT {_TASK_COLUMNS} FROM tasks WHERE status != ? AND id IN "
307
+ "(SELECT depends_on FROM task_deps WHERE task_id = ?) ORDER BY seq LIMIT ? OFFSET ?",
308
+ (Status.DONE.value, task_id, _RELATIONSHIP_LIMIT, offset),
309
+ ).fetchall()
310
+ count = connection.execute(
311
+ "SELECT COUNT(*) FROM tasks WHERE status != ? AND id IN "
312
+ "(SELECT depends_on FROM task_deps WHERE task_id = ?)",
313
+ (Status.DONE.value, task_id),
314
+ ).fetchone()[0]
315
+ return tuple(_rows_to_tasks(connection, rows)), count
316
+
317
+
318
+ def _fetch_dependent_tasks(
319
+ connection: sqlite3.Connection, task_id: str, *, offset: int
320
+ ) -> tuple[tuple[Task, ...], int]:
321
+ rows = connection.execute(
322
+ f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id IN "
323
+ "(SELECT task_id FROM task_deps WHERE depends_on = ?) ORDER BY seq LIMIT ? OFFSET ?",
324
+ (task_id, _RELATIONSHIP_LIMIT, offset),
325
+ ).fetchall()
326
+ count = connection.execute(
327
+ "SELECT COUNT(*) FROM task_deps WHERE depends_on = ?", (task_id,)
328
+ ).fetchone()[0]
329
+ return tuple(_rows_to_tasks(connection, rows)), count
330
+
331
+
332
+ def _family_root_id(connection: sqlite3.Connection, task_id: str) -> str:
333
+ row = connection.execute(
334
+ "WITH RECURSIVE ancestors(id, parent_id) AS ("
335
+ "SELECT id, parent_id FROM tasks WHERE id = ? UNION ALL "
336
+ "SELECT parent.id, parent.parent_id FROM tasks parent "
337
+ "JOIN ancestors child ON parent.id = child.parent_id) "
338
+ "SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT ?",
339
+ (task_id, 1),
340
+ ).fetchone()
341
+ if row is None:
342
+ message = f"task {task_id} has no family root"
343
+ raise ValueError(message)
344
+ return row[0]
345
+
346
+
347
+ def _family_outcomes(connection: sqlite3.Connection, root_id: str) -> tuple[tuple[str, int], ...]:
348
+ rows = connection.execute(
349
+ "WITH RECURSIVE family(id) AS ("
350
+ "SELECT id FROM tasks WHERE id = ? UNION ALL "
351
+ "SELECT child.id FROM tasks child JOIN family parent ON child.parent_id = parent.id) "
352
+ "SELECT tasks.status, tasks.status_reason, COUNT(*) AS count "
353
+ "FROM tasks JOIN family ON family.id = tasks.id GROUP BY tasks.status, tasks.status_reason",
354
+ (root_id,),
355
+ ).fetchall()
356
+ counts = dict.fromkeys(_FAMILY_OUTCOME_ORDER, 0)
357
+ for row in rows:
358
+ outcome = (
359
+ StatusReason.SUPERSEDED.value
360
+ if row["status"] == Status.CANCELLED.value
361
+ and row["status_reason"] == StatusReason.SUPERSEDED.value
362
+ else row["status"]
363
+ )
364
+ counts[outcome] += row["count"]
365
+ return tuple((outcome, counts[outcome]) for outcome in _FAMILY_OUTCOME_ORDER)
366
+
367
+
368
+ def fetch_task_relationships(
369
+ connection: sqlite3.Connection,
370
+ task_id: str,
371
+ *,
372
+ offset: int = 0,
373
+ ) -> TaskRelationships | None:
374
+ """Read one bounded task-relationship page from a consistent database snapshot."""
375
+ if offset < 0:
376
+ message = "task relationship offset cannot be negative"
377
+ raise ValueError(message)
378
+ with transaction(connection):
379
+ task = fetch_task(connection, task_id)
380
+ if task is None:
381
+ return None
382
+ replacement_task_ids: list[str] = []
383
+ if task.status_reason is StatusReason.SUPERSEDED:
384
+ events = fetch_latest_events(
385
+ connection,
386
+ 1,
387
+ kinds=[EventKind.SCOPE_CHANGE],
388
+ task_id=task.id,
389
+ )
390
+ if events:
391
+ recorded_ids = events[0].data.get("new_task_ids")
392
+ if isinstance(recorded_ids, list):
393
+ replacement_task_ids = [
394
+ replacement_id
395
+ for replacement_id in cast("list[object]", recorded_ids)
396
+ if isinstance(replacement_id, str)
397
+ ]
398
+ family_parent = (
399
+ fetch_task(connection, task.parent_id) if task.parent_id is not None else task
400
+ )
401
+ if family_parent is None:
402
+ message = f"task {task.id} has a missing parent {task.parent_id}"
403
+ raise ValueError(message)
404
+ family_children, family_child_count = _fetch_relationship_tasks(
405
+ connection,
406
+ column="parent_id",
407
+ task_id=task.id,
408
+ offset=offset,
409
+ )
410
+ dependencies, dependency_count = _fetch_dependency_tasks(connection, task.id, offset=offset)
411
+ blocking_dependencies, blocking_dependency_count = _fetch_blocking_dependency_tasks(
412
+ connection,
413
+ task.id,
414
+ offset=offset,
415
+ )
416
+ dependents, dependent_count = _fetch_dependent_tasks(connection, task.id, offset=offset)
417
+ replacements = tuple(
418
+ replacement
419
+ for replacement in _fetch_tasks_by_ids(
420
+ connection,
421
+ replacement_task_ids,
422
+ offset=offset,
423
+ )
424
+ if replacement.parent_id == task.id
425
+ )
426
+ root_id = _family_root_id(connection, task.id)
427
+ family_root = fetch_task(connection, root_id)
428
+ if family_root is None:
429
+ message = f"family root {root_id} disappeared while reading {task.id}"
430
+ raise ValueError(message)
431
+ return TaskRelationships(
432
+ family_parent=family_parent,
433
+ family_root=family_root,
434
+ family_children=family_children,
435
+ family_child_count=family_child_count,
436
+ dependencies=dependencies,
437
+ dependency_count=dependency_count,
438
+ blocking_dependencies=blocking_dependencies,
439
+ blocking_dependency_count=blocking_dependency_count,
440
+ dependents=dependents,
441
+ dependent_count=dependent_count,
442
+ replacements=replacements,
443
+ replacement_count=len(replacement_task_ids),
444
+ family_outcomes=_family_outcomes(connection, root_id),
445
+ page_offset=offset,
446
+ page_size=_RELATIONSHIP_LIMIT,
447
+ has_more=any(
448
+ offset + _RELATIONSHIP_LIMIT < count
449
+ for count in (
450
+ family_child_count,
451
+ dependency_count,
452
+ blocking_dependency_count,
453
+ dependent_count,
454
+ len(replacement_task_ids),
455
+ )
456
+ ),
457
+ )
458
+
459
+
460
+ def fetch_task_browser_pages(
461
+ connection: sqlite3.Connection,
462
+ *,
463
+ category: str,
464
+ search: str,
465
+ limit: int,
466
+ page_count: int,
467
+ diagnosed_stall_task_ids: Sequence[str] = (),
468
+ quota_wait_task_ids: Sequence[str] = (),
469
+ ) -> tuple[TaskBrowserPage, ...]:
470
+ """Read a bounded page range from one SQLite snapshot."""
471
+ if page_count < 1:
472
+ message = "task browser page count must be positive"
473
+ raise ValueError(message)
474
+
475
+ with transaction(connection):
476
+ pages: list[TaskBrowserPage] = []
477
+ for page_index in range(page_count):
478
+ offset = page_index * limit
479
+ page = fetch_task_browser_page(
480
+ connection,
481
+ category=category,
482
+ search=search,
483
+ limit=limit,
484
+ offset=offset,
485
+ diagnosed_stall_task_ids=diagnosed_stall_task_ids,
486
+ quota_wait_task_ids=quota_wait_task_ids,
487
+ )
488
+ pages.append(page)
489
+ if offset + len(page.tasks) >= page.matching_count:
490
+ break
491
+ return tuple(pages)
492
+
493
+
494
+ def _task_browser_category(
495
+ category: str,
496
+ quota_wait_task_ids: Sequence[str],
497
+ ) -> tuple[str, tuple[str, ...]]:
498
+ if category == _BROWSER_CATEGORY_ALL:
499
+ return "1 = 1", ()
500
+ if category in _BROWSER_STATUS_FILTERS:
501
+ return "t.status = ?", (category,)
502
+ if category == _BROWSER_CATEGORY_WAITING:
503
+ quota_wait = ""
504
+ parameters: tuple[str, ...] = ()
505
+ if quota_wait_task_ids:
506
+ placeholders = ", ".join("?" for _ in quota_wait_task_ids)
507
+ quota_wait = f" OR t.id IN ({placeholders})"
508
+ parameters = tuple(quota_wait_task_ids)
509
+ return (
510
+ (
511
+ "(t.status = ? OR t.status = ? OR "
512
+ "(t.status IN (?, ?) AND "
513
+ f"(json_array_length(t.holds) > 0 OR t.wait_reason IS NOT NULL{quota_wait})))"
514
+ ),
515
+ (
516
+ Status.QUEUED.value,
517
+ Status.ESCALATED.value,
518
+ Status.RUNNING.value,
519
+ Status.INTEGRATING.value,
520
+ *parameters,
521
+ ),
522
+ )
523
+ statuses = {
524
+ _BROWSER_CATEGORY_ACTIVE: (Status.RUNNING, Status.INTEGRATING, Status.ESCALATED),
525
+ _BROWSER_CATEGORY_HISTORY: (Status.DONE, Status.CANCELLED),
526
+ }
527
+ selected = statuses[category]
528
+ placeholders = ", ".join("?" for _ in selected)
529
+ return (
530
+ f"t.status IN ({placeholders})",
531
+ tuple(status.value for status in selected),
532
+ )
533
+
534
+
535
+ def _task_browser_search(category_where: str, search: str) -> tuple[str, tuple[str, ...]]:
536
+ if not search:
537
+ return category_where, ()
538
+ return (
539
+ (
540
+ f"{category_where} AND "
541
+ "(instr(casefold(t.id), casefold(?)) > 0 OR "
542
+ "instr(casefold(t.title), casefold(?)) > 0)"
543
+ ),
544
+ (search, search),
545
+ )
546
+
547
+
548
+ def _task_browser_order(
549
+ category: str,
550
+ diagnosed_stall_task_ids: Sequence[str],
551
+ ) -> tuple[str, tuple[str, ...]]:
552
+ queued = Status.QUEUED.value
553
+ running = Status.RUNNING.value
554
+ integrating = Status.INTEGRATING.value
555
+ escalated = Status.ESCALATED.value
556
+ done = Status.DONE.value
557
+ cancelled = Status.CANCELLED.value
558
+ open_status = EscalationStatus.OPEN.value
559
+ decision_first = (
560
+ f"CASE WHEN t.status = '{escalated}' OR EXISTS ("
561
+ f"SELECT 1 FROM escalations e WHERE e.task_id = t.id AND e.status = '{open_status}'"
562
+ ") THEN 0 ELSE 1 END"
563
+ )
564
+ if category == _BROWSER_CATEGORY_ACTIVE:
565
+ stall_first = ""
566
+ if diagnosed_stall_task_ids:
567
+ placeholders = ", ".join("?" for _ in diagnosed_stall_task_ids)
568
+ stall_first = f"CASE WHEN t.id IN ({placeholders}) THEN 0 ELSE 1 END, "
569
+ return (
570
+ f"{decision_first}, {stall_first}COALESCE(t.started_at, t.created_at), t.seq",
571
+ tuple(diagnosed_stall_task_ids),
572
+ )
573
+ if category == _BROWSER_CATEGORY_WAITING:
574
+ return (
575
+ (
576
+ f"CASE WHEN t.status = '{queued}' THEN 0 ELSE 1 END, "
577
+ f"CASE WHEN t.status = '{queued}' AND t.requeued_at IS NULL THEN 1 ELSE 0 END, "
578
+ f"CASE WHEN t.status = '{queued}' THEN -t.priority ELSE 0 END, "
579
+ f"CASE WHEN t.status = '{queued}' THEN t.seq END, "
580
+ f"CASE WHEN t.status != '{queued}' THEN COALESCE(t.started_at, t.created_at) END, "
581
+ "t.seq"
582
+ ),
583
+ (),
584
+ )
585
+ if category == _BROWSER_CATEGORY_HISTORY:
586
+ return "t.finished_at DESC, t.seq DESC", ()
587
+ return (
588
+ (
589
+ f"CASE WHEN t.status IN ('{done}', '{cancelled}') THEN 1 ELSE 0 END, "
590
+ f"{decision_first}, "
591
+ f"CASE WHEN t.status = '{queued}' THEN 1 ELSE 0 END, "
592
+ f"CASE WHEN t.status = '{queued}' AND t.requeued_at IS NULL THEN 1 ELSE 0 END, "
593
+ f"CASE WHEN t.status = '{queued}' THEN -t.priority ELSE 0 END, "
594
+ f"CASE WHEN t.status = '{queued}' THEN t.seq END, "
595
+ f"CASE WHEN t.status IN ('{running}', '{integrating}', '{escalated}') "
596
+ "THEN COALESCE(t.started_at, t.created_at) END, "
597
+ f"CASE WHEN t.status IN ('{done}', '{cancelled}') THEN t.finished_at END DESC, "
598
+ "t.seq"
599
+ ),
600
+ (),
601
+ )
602
+
603
+
604
+ class TaskStore:
605
+ """Inserts task rows, applies transitions and updates their scheduling fields."""
606
+
607
+ def __init__(self, connection: sqlite3.Connection, clock: Clock) -> None:
608
+ """Use `connection` (the engine's writer) and `clock` for every write below."""
609
+ self._connection = connection
610
+ self._clock = clock
611
+
612
+ def insert_from_submit(self, submit: Submit) -> Task:
613
+ """Insert a task using the ID allocated by a `Submit` command."""
614
+ task_id = submit.task_id
615
+ if task_id is None:
616
+ message = "submit command has no allocated task id"
617
+ raise ValueError(message)
618
+ seq = int(task_id.removeprefix(TASK_ID_PREFIX))
619
+ overrides: dict[Role, Rung] = {}
620
+ if submit.leader is not None:
621
+ overrides[Role.LEADER] = submit.leader
622
+ if submit.implementer is not None:
623
+ overrides[Role.IMPLEMENTER] = submit.implementer
624
+ with transaction(self._connection):
625
+ self._insert_row(
626
+ task_id=task_id,
627
+ seq=seq,
628
+ parent_id=None,
629
+ origin=TaskOrigin.OPERATOR,
630
+ title=submit.title,
631
+ request=submit.request,
632
+ mode=submit.mode,
633
+ complexity=submit.complexity,
634
+ priority=submit.priority,
635
+ depends_on=submit.depends_on,
636
+ overrides=overrides,
637
+ )
638
+ return self._fetch_or_raise(task_id)
639
+
640
+ def apply_scope_change(
641
+ self,
642
+ task_id: str,
643
+ transition: Transition,
644
+ new_tasks: Sequence[NewTask],
645
+ *,
646
+ origin: TaskOrigin,
647
+ ) -> list[Task]:
648
+ """Atomically transition a parent, enqueue children and repoint its dependents."""
649
+ with transaction(self._connection):
650
+ parent = fetch_task(self._connection, task_id)
651
+ if parent is None:
652
+ message = f"task {task_id} does not exist"
653
+ raise UsageError(message)
654
+ self._validate_scope_references(new_tasks)
655
+ self.apply_transition(task_id, transition)
656
+ if transition.status in (Status.DONE, Status.CANCELLED):
657
+ self.set_slot(task_id, None)
658
+ child_ids = [self._next_task_id() for _ in new_tasks]
659
+ resolved_dependencies = [
660
+ [resolve_dependency(ref, child_ids) for ref in new_task.depends_on]
661
+ for new_task in new_tasks
662
+ ]
663
+ child_dependencies: list[tuple[str, str]] = []
664
+ await_parent = not transition.repoint_dependents
665
+ for child_id, new_task, depends_on in zip(
666
+ child_ids, new_tasks, resolved_dependencies, strict=True
667
+ ):
668
+ self._insert_row(
669
+ task_id=child_id,
670
+ seq=int(child_id.removeprefix(TASK_ID_PREFIX)),
671
+ parent_id=parent.id,
672
+ origin=origin,
673
+ title=new_task.title,
674
+ request=new_task.request,
675
+ mode=None,
676
+ complexity=None,
677
+ priority=new_task.priority,
678
+ depends_on=(),
679
+ overrides={},
680
+ )
681
+ child_dependencies.extend((child_id, dependency) for dependency in depends_on)
682
+ if await_parent and parent.id not in depends_on:
683
+ child_dependencies.append((child_id, parent.id))
684
+ self._connection.executemany(
685
+ "INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)", child_dependencies
686
+ )
687
+ if transition.repoint_dependents:
688
+ self._repoint_dependents(task_id, child_ids)
689
+ self._validate_acyclic()
690
+ return [self._fetch_or_raise(child_id) for child_id in child_ids]
691
+
692
+ def _insert_row(
693
+ self,
694
+ *,
695
+ task_id: str,
696
+ seq: int,
697
+ parent_id: str | None,
698
+ origin: TaskOrigin,
699
+ title: str,
700
+ request: str,
701
+ mode: Mode | None,
702
+ complexity: ComplexityName | None,
703
+ priority: int,
704
+ depends_on: Sequence[str],
705
+ overrides: dict[Role, Rung],
706
+ ) -> None:
707
+ self._connection.execute(
708
+ """
709
+ INSERT INTO tasks (
710
+ id, seq, parent_id, origin, title, request, mode, complexity,
711
+ complexity_explicit, priority, status, overrides, created_at
712
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
713
+ """,
714
+ (
715
+ task_id,
716
+ seq,
717
+ parent_id,
718
+ origin.value,
719
+ title,
720
+ request,
721
+ mode.value if mode is not None else None,
722
+ complexity or DEFAULT_COMPLEXITY,
723
+ complexity is not None,
724
+ priority,
725
+ Status.QUEUED.value,
726
+ dump_json(_OVERRIDES_ADAPTER, overrides),
727
+ format_timestamp(self._clock.now()),
728
+ ),
729
+ )
730
+ self._connection.executemany(
731
+ "INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)",
732
+ [(task_id, dep) for dep in depends_on],
733
+ )
734
+
735
+ def _next_task_id(self) -> str:
736
+ return f"{TASK_ID_PREFIX}{next_sequence(self._connection, TASK_SEQUENCE)}"
737
+
738
+ def _validate_scope_references(self, new_tasks: Sequence[NewTask]) -> None:
739
+ existing_references = {
740
+ ref
741
+ for new_task in new_tasks
742
+ for ref in new_task.depends_on
743
+ if not ref.startswith(SIBLING_REFERENCE_PREFIX)
744
+ }
745
+ if existing_references:
746
+ placeholders = ", ".join("?" for _ in existing_references)
747
+ rows = self._connection.execute(
748
+ f"SELECT id FROM tasks WHERE id IN ({placeholders})",
749
+ tuple(existing_references),
750
+ ).fetchall()
751
+ found = {row["id"] for row in rows}
752
+ missing = existing_references - found
753
+ if missing:
754
+ reference = min(missing)
755
+ message = f"scope change dependency {reference!r} does not exist"
756
+ raise UsageError(message)
757
+
758
+ def _validate_acyclic(self) -> None:
759
+ graph: dict[str, list[str]] = {}
760
+ for row in self._connection.execute("SELECT task_id, depends_on FROM task_deps"):
761
+ graph.setdefault(row["task_id"], []).append(row["depends_on"])
762
+ cycle = find_dependency_cycle(graph)
763
+ if cycle:
764
+ message = f"scope change creates a dependency cycle: {', '.join(cycle)}"
765
+ raise UsageError(message)
766
+
767
+ def _fetch_or_raise(self, task_id: str) -> Task:
768
+ task = fetch_task(self._connection, task_id)
769
+ return require_after_insert(task, f"task {task_id} vanished immediately after insert")
770
+
771
+ def get(self, task_id: str) -> Task | None:
772
+ """Read one task by id, or `None` if it doesn't exist."""
773
+ return fetch_task(self._connection, task_id)
774
+
775
+ def require(self, task_id: str) -> Task:
776
+ """Read one task by id, or raise when it disappeared."""
777
+ task = self.get(task_id)
778
+ if task is None:
779
+ message = f"{task_id} vanished while its flow was running"
780
+ raise InvariantError(message)
781
+ return task
782
+
783
+ def list_active(self) -> list[Task]:
784
+ """Read every task holding a slot: `running`, `integrating` or `escalated`."""
785
+ return fetch_tasks(self._connection, statuses=ACTIVE_STATUSES)
786
+
787
+ def list_all(self) -> list[Task]:
788
+ """Read every task, the universe `domain.scheduling.pick_ready` needs for dep lookups."""
789
+ return fetch_tasks(self._connection)
790
+
791
+ def apply_transition(self, task_id: str, transition: Transition) -> None:
792
+ """Persist `transition`'s step, status, iteration, counters, rungs and triage results.
793
+
794
+ The decision is reconciled with the stored row in the same write
795
+ (`Transition.rebased_onto`): only what it changed relative to its `base` is applied, and a
796
+ rung the operator set since wins over the turn's rung move and rejection count.
797
+ Overrides merge the same way: the transition sets its own roles and clears a role only
798
+ while it still holds the rung the transition saw. Complexity and title are written only
799
+ when the transition sets them.
800
+ """
801
+ current = self.require(task_id)
802
+ overrides = self._merged_overrides(current, transition)
803
+ transition = transition.rebased_onto(current)
804
+ now = format_timestamp(self._clock.now())
805
+ status = transition.status.value
806
+ self._connection.execute(
807
+ """
808
+ UPDATE tasks SET
809
+ step = ?, status = ?, status_reason = ?, iteration = ?,
810
+ counters = ?, rung_index = ?, overrides = ?,
811
+ complexity = COALESCE(?, complexity),
812
+ complexity_explicit = complexity_explicit OR ? IS NOT NULL,
813
+ mode = COALESCE(?, mode), title = COALESCE(?, title),
814
+ started_at = CASE WHEN ? = ? AND started_at IS NULL THEN ? ELSE started_at END,
815
+ finished_at = CASE WHEN ? IN (?, ?) AND finished_at IS NULL
816
+ THEN ? ELSE finished_at END,
817
+ wait_reason = CASE WHEN ? IN (?, ?) THEN NULL ELSE wait_reason END
818
+ WHERE id = ?
819
+ """,
820
+ (
821
+ transition.step.value if transition.step is not None else None,
822
+ status,
823
+ transition.status_reason,
824
+ transition.iteration,
825
+ dump_json(_COUNTERS_ADAPTER, transition.counters),
826
+ dump_json(_RUNG_INDEX_ADAPTER, transition.rung_index),
827
+ dump_json(_OVERRIDES_ADAPTER, overrides),
828
+ transition.complexity,
829
+ transition.complexity,
830
+ transition.mode.value if transition.mode is not None else None,
831
+ transition.title,
832
+ status,
833
+ Status.RUNNING.value,
834
+ now,
835
+ status,
836
+ Status.DONE.value,
837
+ Status.CANCELLED.value,
838
+ now,
839
+ status,
840
+ Status.DONE.value,
841
+ Status.CANCELLED.value,
842
+ task_id,
843
+ ),
844
+ )
845
+
846
+ @staticmethod
847
+ def _merged_overrides(current: Task, transition: Transition) -> dict[Role, Rung]:
848
+ kept = {
849
+ role: rung
850
+ for role, rung in current.overrides.items()
851
+ if transition.cleared_overrides.get(role) != rung
852
+ }
853
+ return {**kept, **transition.set_overrides}
854
+
855
+ def set_holds(self, task_id: str, holds: frozenset[Hold]) -> None:
856
+ """Persist `task_id`'s task-scoped holds."""
857
+ self._connection.execute(
858
+ "UPDATE tasks SET holds = ? WHERE id = ?",
859
+ (dump_json(_HOLDS_ADAPTER, holds), task_id),
860
+ )
861
+
862
+ def set_priority(self, task_id: str, priority: int) -> None:
863
+ """Set `task_id`'s dispatch priority (`prioritize`)."""
864
+ self._connection.execute("UPDATE tasks SET priority = ? WHERE id = ?", (priority, task_id))
865
+
866
+ def set_wait_reason(self, task_id: str, reason: WaitReason | None) -> None:
867
+ """Persist why the scheduler isn't dispatching `task_id` yet."""
868
+ self._connection.execute(
869
+ "UPDATE tasks SET wait_reason = ? WHERE id = ?",
870
+ (reason.value if reason is not None else None, task_id),
871
+ )
872
+
873
+ def finish(
874
+ self,
875
+ task_id: str,
876
+ transition: Transition,
877
+ event: EventKind,
878
+ *,
879
+ message: str,
880
+ data: dict[str, Any],
881
+ ) -> None:
882
+ """Enter a terminal `transition`, free the slot and append its terminal `event` at once.
883
+
884
+ Status and event land in one transaction, so a restart never sees a terminal task
885
+ without its terminal event, nor the event twice.
886
+ """
887
+ with transaction(self._connection):
888
+ self.apply_transition(task_id, transition)
889
+ self.set_slot(task_id, None)
890
+ EventLog(self._connection, self._clock).append(
891
+ event, task_id=task_id, message=message, data=data
892
+ )
893
+
894
+ def set_slot(self, task_id: str, slot: int | None) -> None:
895
+ """Assign or clear `task_id`'s dispatch slot."""
896
+ self._connection.execute("UPDATE tasks SET slot = ? WHERE id = ?", (slot, task_id))
897
+
898
+ def set_window(self, task_id: str, window_id: str | None) -> None:
899
+ """Record `task_id`'s tmux window id."""
900
+ self._connection.execute(
901
+ "UPDATE tasks SET window_id = ? WHERE id = ?", (window_id, task_id)
902
+ )
903
+
904
+ def set_panes(self, task_id: str, panes: dict[Role, str]) -> None:
905
+ """Record `task_id`'s `{role: pane_id}` map, the only place pane ids live."""
906
+ self._connection.execute(
907
+ "UPDATE tasks SET panes = ? WHERE id = ?",
908
+ (dump_json(_PANES_ADAPTER, panes), task_id),
909
+ )
910
+
911
+ def set_repo(self, task_id: str, repo: RepoState) -> None:
912
+ """Record `task_id`'s checkout state."""
913
+ self._connection.execute(
914
+ "UPDATE tasks SET repo = ? WHERE id = ?",
915
+ (dump_json(_REPO_ADAPTER, repo), task_id),
916
+ )
917
+
918
+ def set_requeued(self, task_id: str) -> None:
919
+ """Mark `task_id` requeued now, giving it dispatch precedence."""
920
+ self._connection.execute(
921
+ "UPDATE tasks SET requeued_at = ? WHERE id = ?",
922
+ (format_timestamp(self._clock.now()), task_id),
923
+ )
924
+
925
+ def set_summary(self, task_id: str, summary: str) -> None:
926
+ """Record `task_id`'s final summary."""
927
+ self._connection.execute("UPDATE tasks SET summary = ? WHERE id = ?", (summary, task_id))
928
+
929
+ def set_depends_on(self, task_id: str, depends_on: Sequence[str]) -> None:
930
+ """Replace `task_id`'s dependency set, including sibling references from a plan."""
931
+ with transaction(self._connection):
932
+ self._connection.execute("DELETE FROM task_deps WHERE task_id = ?", (task_id,))
933
+ self._connection.executemany(
934
+ "INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)",
935
+ [(task_id, dep) for dep in depends_on],
936
+ )
937
+
938
+ def _repoint_dependents(self, old_task_id: str, new_task_ids: Sequence[str]) -> None:
939
+ dependents = [
940
+ row["task_id"]
941
+ for row in self._connection.execute(
942
+ "SELECT task_id FROM task_deps WHERE depends_on = ?", (old_task_id,)
943
+ ).fetchall()
944
+ ]
945
+ self._connection.execute("DELETE FROM task_deps WHERE depends_on = ?", (old_task_id,))
946
+ self._connection.executemany(
947
+ "INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)",
948
+ [(dependent, new_task_id) for dependent in dependents for new_task_id in new_task_ids],
949
+ )