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,92 @@
1
+ """Row retention: the finished history that can be pruned from the state DB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.commands import CommandStatus
8
+ from shikiru.domain.escalation import EscalationStatus
9
+ from shikiru.domain.events import EventKind
10
+ from shikiru.domain.tasks import Status
11
+ from shikiru.store.database import format_timestamp
12
+
13
+ if TYPE_CHECKING:
14
+ import sqlite3 # noqa: TID251
15
+ from collections.abc import Sequence
16
+ from datetime import datetime
17
+
18
+ _FINISHED = (Status.DONE.value, Status.CANCELLED.value)
19
+ _FAULT_KINDS = (EventKind.ENGINE_FAULT.value, EventKind.ENGINE_RECOVERED.value)
20
+ _NEWEST_EVENT = "id < (SELECT MAX(id) FROM events)"
21
+
22
+
23
+ class StateRetention:
24
+ """Selects and deletes rows past the retention horizon, never the ones still needed.
25
+
26
+ The newest event and command row always stay, so ids are never reused: a reused command id
27
+ would match the messages and events filed under the old one. Events not yet covered by the
28
+ notification cursor stay, so an urgent event is never pruned before it is delivered.
29
+ """
30
+
31
+ def __init__(self, connection: sqlite3.Connection) -> None:
32
+ """Use `connection`, the engine's writer."""
33
+ self._connection = connection
34
+
35
+ def settled_task_ids(self, finished_before: datetime) -> list[str]:
36
+ """Return finished tasks older than `finished_before` that still have events.
37
+
38
+ A task with an open escalation is not settled: its packet and evidence are still read.
39
+ """
40
+ rows = self._connection.execute(
41
+ f"""
42
+ SELECT id FROM tasks
43
+ WHERE status IN ({_marks(_FINISHED)})
44
+ AND COALESCE(finished_at, created_at) < ?
45
+ AND EXISTS (SELECT 1 FROM events WHERE events.task_id = tasks.id)
46
+ AND NOT EXISTS (
47
+ SELECT 1 FROM escalations
48
+ WHERE escalations.task_id = tasks.id AND escalations.status = ?
49
+ )
50
+ ORDER BY seq
51
+ """,
52
+ (*_FINISHED, format_timestamp(finished_before), EscalationStatus.OPEN.value),
53
+ ).fetchall()
54
+ return [row["id"] for row in rows]
55
+
56
+ def delete_task_events(self, task_ids: Sequence[str], *, delivered_up_to: int) -> int:
57
+ """Delete the events of `task_ids` that were delivered; return how many went."""
58
+ deleted = 0
59
+ for task_id in task_ids:
60
+ deleted += self._connection.execute(
61
+ f"DELETE FROM events WHERE task_id = ? AND id <= ? AND {_NEWEST_EVENT}",
62
+ (task_id, delivered_up_to),
63
+ ).rowcount
64
+ return deleted
65
+
66
+ def delete_engine_events(self, before: datetime, *, delivered_up_to: int) -> int:
67
+ """Delete delivered engine-level events older than `before`, but not fault records.
68
+
69
+ A fault or its recovery stays: `status` derives the open fault streaks from them.
70
+ """
71
+ return self._connection.execute(
72
+ f"""
73
+ DELETE FROM events
74
+ WHERE task_id IS NULL AND ts < ? AND id <= ? AND {_NEWEST_EVENT}
75
+ AND kind NOT IN ({_marks(_FAULT_KINDS)})
76
+ """,
77
+ (format_timestamp(before), delivered_up_to, *_FAULT_KINDS),
78
+ ).rowcount
79
+
80
+ def delete_finished_commands(self, before: datetime) -> int:
81
+ """Delete answered commands older than `before`; a pending command is never deleted."""
82
+ return self._connection.execute(
83
+ """
84
+ DELETE FROM commands
85
+ WHERE status != ? AND ts < ? AND id < (SELECT MAX(id) FROM commands)
86
+ """,
87
+ (CommandStatus.PENDING.value, format_timestamp(before)),
88
+ ).rowcount
89
+
90
+
91
+ def _marks(values: Sequence[str]) -> str:
92
+ return ", ".join("?" for _ in values)
shikiru/store/runs.py ADDED
@@ -0,0 +1,227 @@
1
+ """Agent session runs: one per task role's session lineage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.agents.contracts import (
8
+ UNKNOWN_BILLING_DECLARATION,
9
+ BillingDeclaration,
10
+ Usage,
11
+ )
12
+ from shikiru.domain.runs import Run, RunStatus
13
+ from shikiru.domain.tasks import Role
14
+ from shikiru.store.database import (
15
+ format_timestamp,
16
+ parse_required_timestamp,
17
+ parse_timestamp,
18
+ require_after_insert,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ import sqlite3 # noqa: TID251
23
+
24
+ from shikiru.agents.contracts import Rung
25
+ from shikiru.clock import Clock
26
+
27
+ _RUN_COLUMNS = (
28
+ "id, task_id, role, agent, model, effort, session_id, resume_command, "
29
+ "last_message, status, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, "
30
+ "cost_usd, cost_basis, context_used_pct, usage_reported, billing_mode, billing_source, "
31
+ "started_at, ended_at"
32
+ )
33
+ MAX_TAKEN_OVER_ROLE_COUNT = len(Role)
34
+
35
+
36
+ def _row_to_run(row: sqlite3.Row) -> Run:
37
+ return Run(
38
+ id=row["id"],
39
+ task_id=row["task_id"],
40
+ role=Role(row["role"]),
41
+ agent=row["agent"],
42
+ model=row["model"],
43
+ effort=row["effort"],
44
+ session_id=row["session_id"],
45
+ resume_command=row["resume_command"],
46
+ last_message=row["last_message"],
47
+ status=RunStatus(row["status"]),
48
+ usage=Usage(
49
+ input_tokens=row["input_tokens"],
50
+ output_tokens=row["output_tokens"],
51
+ cache_read_tokens=row["cache_read_tokens"],
52
+ cache_write_tokens=row["cache_write_tokens"],
53
+ cost_usd=row["cost_usd"],
54
+ cost_basis=row["cost_basis"],
55
+ context_used_pct=row["context_used_pct"],
56
+ ),
57
+ usage_reported=(bool(row["usage_reported"]) if row["usage_reported"] is not None else None),
58
+ billing_mode=row["billing_mode"],
59
+ billing_source=row["billing_source"],
60
+ started_at=parse_required_timestamp(row["started_at"]),
61
+ ended_at=parse_timestamp(row["ended_at"]),
62
+ )
63
+
64
+
65
+ def fetch_run(connection: sqlite3.Connection, run_id: int) -> Run | None:
66
+ """Read one run by id, or `None` if it doesn't exist."""
67
+ row = connection.execute(f"SELECT {_RUN_COLUMNS} FROM runs WHERE id = ?", (run_id,)).fetchone()
68
+ return _row_to_run(row) if row is not None else None
69
+
70
+
71
+ def fetch_runs_for_task(connection: sqlite3.Connection, task_id: str) -> list[Run]:
72
+ """Read every run recorded for `task_id`, most recently started first."""
73
+ rows = connection.execute(
74
+ f"SELECT {_RUN_COLUMNS} FROM runs WHERE task_id = ? ORDER BY id DESC", (task_id,)
75
+ ).fetchall()
76
+ return [_row_to_run(row) for row in rows]
77
+
78
+
79
+ def fetch_taken_over_roles_for_task(
80
+ connection: sqlite3.Connection,
81
+ task_id: str,
82
+ ) -> tuple[Role, ...]:
83
+ rows = connection.execute(
84
+ "SELECT role FROM runs WHERE task_id = ? AND status = ? ORDER BY id DESC LIMIT ?",
85
+ (task_id, RunStatus.TAKEN_OVER.value, MAX_TAKEN_OVER_ROLE_COUNT),
86
+ ).fetchall()
87
+ return tuple(Role(row["role"]) for row in rows)
88
+
89
+
90
+ def fetch_all_runs(connection: sqlite3.Connection) -> list[Run]:
91
+ """Read every run, most recently started first."""
92
+ rows = connection.execute(f"SELECT {_RUN_COLUMNS} FROM runs ORDER BY id DESC").fetchall()
93
+ return [_row_to_run(row) for row in rows]
94
+
95
+
96
+ class RunStore:
97
+ """Starts, updates and reads agent session runs."""
98
+
99
+ def __init__(self, connection: sqlite3.Connection, clock: Clock) -> None:
100
+ """Use `connection` (the engine's writer) and `clock` for every write below."""
101
+ self._connection = connection
102
+ self._clock = clock
103
+
104
+ def start(
105
+ self,
106
+ task_id: str,
107
+ role: Role,
108
+ rung: Rung,
109
+ *,
110
+ billing_declaration: BillingDeclaration = UNKNOWN_BILLING_DECLARATION,
111
+ ) -> Run:
112
+ """Start a new live run for `task_id`'s `role` on `rung`."""
113
+ cursor = self._connection.execute(
114
+ """
115
+ INSERT INTO runs (
116
+ task_id, role, agent, model, effort, status, usage_reported,
117
+ billing_mode, billing_source, started_at
118
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
119
+ """,
120
+ (
121
+ task_id,
122
+ role.value,
123
+ rung.agent,
124
+ rung.model,
125
+ rung.effort,
126
+ RunStatus.LIVE.value,
127
+ billing_declaration.mode,
128
+ billing_declaration.source,
129
+ format_timestamp(self._clock.now()),
130
+ ),
131
+ )
132
+ run_id = require_after_insert(
133
+ cursor.lastrowid, "sqlite did not allocate a rowid for the new run"
134
+ )
135
+ run = fetch_run(self._connection, run_id)
136
+ return require_after_insert(run, f"run {run_id} vanished immediately after insert")
137
+
138
+ def get_live(self, task_id: str, role: Role) -> Run | None:
139
+ """Return `task_id`'s live run for `role`, if any."""
140
+ return self._get_with_status(task_id, role, RunStatus.LIVE)
141
+
142
+ def get_taken_over(self, task_id: str, role: Role) -> Run | None:
143
+ """Return `task_id`'s run for `role` that the Operator has taken over, if any."""
144
+ return self._get_with_status(task_id, role, RunStatus.TAKEN_OVER)
145
+
146
+ def mark_taken_over(self, run_id: int) -> None:
147
+ """Mark `run_id` taken over: its session runs interactively in the Operator's pane."""
148
+ self._set_status_keeping_open(run_id, RunStatus.TAKEN_OVER)
149
+
150
+ def mark_live(self, run_id: int) -> None:
151
+ """Mark a taken-over `run_id` live again: the engine drives its session."""
152
+ self._set_status_keeping_open(run_id, RunStatus.LIVE)
153
+
154
+ def has_taken_over(self, task_id: str) -> bool:
155
+ """Report whether the Operator has taken over any role of `task_id`."""
156
+ row = self._connection.execute(
157
+ "SELECT 1 FROM runs WHERE task_id = ? AND status = ? LIMIT 1",
158
+ (task_id, RunStatus.TAKEN_OVER.value),
159
+ ).fetchone()
160
+ return row is not None
161
+
162
+ def _get_with_status(self, task_id: str, role: Role, status: RunStatus) -> Run | None:
163
+ row = self._connection.execute(
164
+ f"SELECT {_RUN_COLUMNS} FROM runs WHERE task_id = ? AND role = ? AND status = ?",
165
+ (task_id, role.value, status.value),
166
+ ).fetchone()
167
+ return _row_to_run(row) if row is not None else None
168
+
169
+ def _set_status_keeping_open(self, run_id: int, status: RunStatus) -> None:
170
+ self._connection.execute("UPDATE runs SET status = ? WHERE id = ?", (status.value, run_id))
171
+
172
+ def for_task(self, task_id: str) -> list[Run]:
173
+ """Return every run recorded for `task_id`."""
174
+ return fetch_runs_for_task(self._connection, task_id)
175
+
176
+ def set_session(self, run_id: int, session_id: str | None, resume_command: str | None) -> None:
177
+ """Record `run_id`'s launched session id and resume command.
178
+
179
+ `session_id` is `None` when `SessionStarted` says the session can't be resumed,
180
+ and `resume_command` is `None` when the agent's `resume_command()` returns none.
181
+ """
182
+ self._connection.execute(
183
+ "UPDATE runs SET session_id = ?, resume_command = ? WHERE id = ?",
184
+ (session_id, resume_command, run_id),
185
+ )
186
+
187
+ def set_rung(self, run_id: int, rung: Rung) -> None:
188
+ """Record a same-agent model or effort change on an existing session."""
189
+ self._connection.execute(
190
+ "UPDATE runs SET model = ?, effort = ? WHERE id = ?",
191
+ (rung.model, rung.effort, run_id),
192
+ )
193
+
194
+ def set_last_message(self, run_id: int, message: str) -> None:
195
+ """Persist `run_id`'s latest known message from the agent."""
196
+ self._connection.execute("UPDATE runs SET last_message = ? WHERE id = ?", (message, run_id))
197
+
198
+ def record_usage(self, run_id: int, usage: Usage) -> None:
199
+ """Overwrite `run_id`'s usage with `usage`'s latest cumulative snapshot."""
200
+ self._connection.execute(
201
+ """
202
+ UPDATE runs SET input_tokens = ?, output_tokens = ?, cache_read_tokens = ?,
203
+ cache_write_tokens = ?, cost_usd = ?, cost_basis = ?, context_used_pct = ?,
204
+ usage_reported = 1
205
+ WHERE id = ?
206
+ """,
207
+ (
208
+ usage.input_tokens,
209
+ usage.output_tokens,
210
+ usage.cache_read_tokens,
211
+ usage.cache_write_tokens,
212
+ usage.cost_usd,
213
+ usage.cost_basis,
214
+ usage.context_used_pct,
215
+ run_id,
216
+ ),
217
+ )
218
+
219
+ def close(self, run_id: int) -> None:
220
+ """Mark `run_id` closed: the task ended, the session is not resumable."""
221
+ self._set_status(run_id, RunStatus.CLOSED)
222
+
223
+ def _set_status(self, run_id: int, status: RunStatus) -> None:
224
+ self._connection.execute(
225
+ "UPDATE runs SET status = ?, ended_at = ? WHERE id = ?",
226
+ (status.value, format_timestamp(self._clock.now()), run_id),
227
+ )