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,2314 @@
1
+ """Compose ready-to-render dashboard data from state, settings and services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter
7
+ from dataclasses import dataclass, replace
8
+ from datetime import datetime, timedelta
9
+ from functools import partial
10
+ from operator import attrgetter
11
+ from typing import TYPE_CHECKING, Protocol
12
+
13
+ from shikiru.agents.contracts import (
14
+ BILLING_MODE_API,
15
+ BILLING_MODE_SUBSCRIPTION,
16
+ BILLING_MODE_UNKNOWN,
17
+ COST_BASIS_UNKNOWN,
18
+ )
19
+ from shikiru.clock import to_local
20
+ from shikiru.dashboard.contract import (
21
+ AgentUsageView,
22
+ AttentionGroupView,
23
+ AttentionView,
24
+ BlockerEvidenceView,
25
+ CommandFeedbackEntryView,
26
+ CommandFeedbackStatus,
27
+ CommandHistoryView,
28
+ DialogueBubbleView,
29
+ DiskView,
30
+ DockerView,
31
+ EngineFaultView,
32
+ EngineStatusView,
33
+ EscalationPanelView,
34
+ HeaderView,
35
+ HeldTaskStateView,
36
+ PublicationState,
37
+ PublicationView,
38
+ QueueRow,
39
+ QuotaBlockView,
40
+ QuotaEvidenceView,
41
+ QuotaGaugeView,
42
+ QuotaReadingView,
43
+ QuotaWaitView,
44
+ RecordedUsageView,
45
+ RepoStatus,
46
+ ResourceView,
47
+ TaskBlockerView,
48
+ TaskBrowserFilter,
49
+ TaskBrowserRow,
50
+ TaskBrowserView,
51
+ TaskDetailView,
52
+ TaskFamilyOutcomeView,
53
+ TaskMilestoneView,
54
+ TaskReferenceView,
55
+ TaskRelationshipsView,
56
+ TaskReportView,
57
+ TaskSessionView,
58
+ TaskTestView,
59
+ TaskTimelineCategory,
60
+ TaskTimelineEntryView,
61
+ TaskTimelinePageView,
62
+ TaskTimelineSource,
63
+ WorkspaceSummaryView,
64
+ )
65
+ from shikiru.dashboard.fault_guidance import RetryPolicy, engine_fault_view
66
+ from shikiru.dashboard.session_history import project_task_sessions
67
+ from shikiru.dashboard.tick import TickMemo, tick_now
68
+ from shikiru.domain.acceptance import acceptance_criteria_from_request
69
+ from shikiru.domain.briefing import (
70
+ escalation_remaining,
71
+ pull_request_line,
72
+ reported_tests,
73
+ verdict_counts,
74
+ )
75
+ from shikiru.domain.commands import CommandStatus, ResolutionKind
76
+ from shikiru.domain.decisions import derive_decisions
77
+ from shikiru.domain.engine_uptime import project_engine_uptime
78
+ from shikiru.domain.escalation_evidence import parse_escalation_packet
79
+ from shikiru.domain.events import EventKind
80
+ from shikiru.domain.ladder import move_up, pick_rung
81
+ from shikiru.domain.levels import free_space_level, usage_level
82
+ from shikiru.domain.motion import MotionStampKind, QuotaMotionState
83
+ from shikiru.domain.quota_waits import (
84
+ QUOTA_NOT_REPORTED,
85
+ current_quota_wait_events,
86
+ engine_retry_note,
87
+ next_quota_reload,
88
+ quota_banner,
89
+ quota_wait_description,
90
+ )
91
+ from shikiru.domain.reports import (
92
+ ConsultAnswer,
93
+ FeedbackAnalysis,
94
+ ImplementationReport,
95
+ PlanReport,
96
+ TriageReport,
97
+ Verdict,
98
+ )
99
+ from shikiru.domain.results import derive_task_results, new_task_ids, tracks_live_work
100
+ from shikiru.domain.runs import RunStatus, has_positive_token_evidence
101
+ from shikiru.domain.scheduling import (
102
+ WaitReason,
103
+ dispatch_sort_key,
104
+ occupied_slot_ids,
105
+ quota_blocked_agents,
106
+ quota_blocking_agent,
107
+ quota_wait_reroutes,
108
+ running_quota_wait_moves,
109
+ task_agents,
110
+ )
111
+ from shikiru.domain.steps import ACTING_ROLE, Step, task_acting_role
112
+ from shikiru.domain.tasks import ACTIVE_STATUSES, Hold, Role, Status, Task
113
+ from shikiru.domain.timeline import TIMELINE_EVIDENCE_CATEGORY
114
+ from shikiru.domain.timeline_phrases import GLYPH_ATTENTION, Tone
115
+ from shikiru.domain.workflow import WorkflowProgress, WorkflowReport, project_workflow
116
+ from shikiru.errors import ShikiruError
117
+ from shikiru.operator.queries import (
118
+ AGENT_REPORT_SOURCE,
119
+ OperatorTaskAction,
120
+ aggregate_usage,
121
+ )
122
+ from shikiru.operator.strip_views import task_strip_data
123
+ from shikiru.ui.age import SECONDS_PER_DAY, format_age, format_duration
124
+ from shikiru.ui.age import format_short_age as _age
125
+ from shikiru.ui.glyphs import GLYPH_STALLED, SPINNER_FRAMES
126
+ from shikiru.ui.section import GROUP_SEPARATOR
127
+ from shikiru.ui.strip import PanelState, StampKind, StripStamp, TaskStripData
128
+ from shikiru.view_log import turn_iteration
129
+
130
+ if TYPE_CHECKING:
131
+ from collections.abc import Callable, Iterable, Sequence
132
+
133
+ from shikiru.agents.contracts import QuotaWindow
134
+ from shikiru.clock import Clock
135
+ from shikiru.dashboard.live_settings import LiveSettings
136
+ from shikiru.dashboard.motion import DashboardMotion
137
+ from shikiru.dashboard.services import TaskWorkSample, TaskWorkService
138
+ from shikiru.domain.briefing import PullRequestLine, ReportedTests
139
+ from shikiru.domain.decisions import TaskDecisions
140
+ from shikiru.domain.events import Event
141
+ from shikiru.domain.faults import FaultStreak
142
+ from shikiru.domain.motion import MotionFrame, TaskMotionFrame
143
+ from shikiru.domain.reports import Commit
144
+ from shikiru.domain.results import TaskResults
145
+ from shikiru.domain.runs import Run
146
+ from shikiru.domain.tasks import Task
147
+ from shikiru.operator.queries import (
148
+ EscalationView,
149
+ OperatorQueries,
150
+ ReportRow,
151
+ TaskDetail,
152
+ UsageView,
153
+ )
154
+ from shikiru.process import ProcessIdentity
155
+ from shikiru.store.quotas import AgentBlock
156
+ from shikiru.store.reader import StateReader
157
+ from shikiru.store.tasks import TaskBrowserPage, TaskRelationships
158
+
159
+ DIALOGUE_LINE_LIMIT = 20
160
+ ALERT_TIME_FORMAT = "%H:%M:%S"
161
+ PUSH_TIME_FORMAT = "%H:%M"
162
+ SECONDS_PER_MINUTE = 60
163
+ MINUTES_PER_HOUR = 60
164
+ TIME_UNKNOWN = "\N{EN DASH}"
165
+ TASK_TIMELINE_READ_ERROR_ID = "timeline-read-error"
166
+ TASK_TIMELINE_READ_ERROR_SUMMARY = "Task evidence could not be read"
167
+ ENGINE_LIFECYCLE_EVENT_LIMIT = 2
168
+ TASK_MODEL_HISTORY_EVENT_LIMIT = 50
169
+ RESULTS_PLAN_CHANGE_LIMIT = 20
170
+ DECISION_EVENT_LIMIT = 60
171
+ DECISION_EVENTS = (
172
+ EventKind.VERDICT,
173
+ EventKind.ESCALATION,
174
+ EventKind.ESCALATION_RESOLVED,
175
+ EventKind.SCOPE_CHANGE,
176
+ EventKind.RUNG_CHANGED,
177
+ EventKind.OPERATOR_MESSAGE,
178
+ EventKind.TAKEN_OVER,
179
+ EventKind.HANDED_BACK,
180
+ EventKind.HOLD_SET,
181
+ EventKind.HOLD_CLEARED,
182
+ EventKind.TASK_RETRIED,
183
+ EventKind.TASK_CANCELLED,
184
+ EventKind.PR_OPENED,
185
+ EventKind.PR_WITHDRAWN,
186
+ EventKind.TASK_DONE,
187
+ )
188
+ WORKFLOW_EVENT_LIMIT = 200
189
+ WORKFLOW_EVENTS = (
190
+ EventKind.TASK_QUEUED,
191
+ EventKind.STEP_STARTED,
192
+ EventKind.TURN_STARTED,
193
+ EventKind.VERDICT,
194
+ )
195
+ TASK_MODEL_HISTORY_EVENTS = (
196
+ EventKind.TURN_STARTED,
197
+ EventKind.TURN_COMPLETED,
198
+ EventKind.TURN_FAILED,
199
+ EventKind.TURN_STOPPED,
200
+ )
201
+ _ENGINE_LIFECYCLE_EVENTS = (EventKind.ENGINE_STARTED, EventKind.ENGINE_STOPPED)
202
+ AGENT_REPORTED_TESTS = "agent-reported"
203
+ TASK_ROLES = (Role.LEADER, Role.IMPLEMENTER)
204
+ SETUP_STEP = "setup"
205
+ UNSTARTED_STEP = "not started"
206
+ CONSULT_STEP = "consult"
207
+ INTEGRATION_STEP = "integration"
208
+ REWORK_STEP = "rework"
209
+ _ACTIVITY_KINDS = (
210
+ EventKind.VERDICT,
211
+ EventKind.TURN_COMPLETED,
212
+ EventKind.TURN_FAILED,
213
+ EventKind.TURN_STOPPED,
214
+ EventKind.HELPER_ACTION,
215
+ EventKind.RUNG_CHANGED,
216
+ EventKind.QUOTA_BLOCKED,
217
+ EventKind.QUOTA_CLEARED,
218
+ EventKind.SCOPE_CHANGE,
219
+ EventKind.HOLD_SET,
220
+ EventKind.HOLD_CLEARED,
221
+ EventKind.TASK_RETRIED,
222
+ EventKind.INTEGRATION_WAITING,
223
+ EventKind.TASK_DONE,
224
+ EventKind.ENGINE_FAULT,
225
+ EventKind.ENGINE_RECOVERED,
226
+ )
227
+ _ACTIVE_TURN_STATUSES = (Status.RUNNING, Status.INTEGRATING)
228
+ _MOTION_FILTERS = frozenset(
229
+ {
230
+ TaskBrowserFilter.ACTIVE,
231
+ TaskBrowserFilter.ALL,
232
+ TaskBrowserFilter.RUNNING,
233
+ TaskBrowserFilter.INTEGRATING,
234
+ TaskBrowserFilter.ESCALATED,
235
+ }
236
+ )
237
+ _RECORDED_REASON = "Recorded scheduler reason"
238
+ _UNRECORDED_REASON = "Waiting on"
239
+ _RECORDED_HOLD = "Recorded hold"
240
+ _RECORDED_TAKEOVER = "Recorded takeover"
241
+ _PAUSED_HEADLINE = "Paused by operator"
242
+ _INTERVENTION_HEADLINE = "Held by operator"
243
+ _PAUSE_HOLD_DETAIL = "Pause hold · Resume clears this hold when submitted."
244
+ _PAUSE_HOLD_EVIDENCE = f"{_RECORDED_HOLD}{GROUP_SEPARATOR}{Hold.PAUSED.value}"
245
+ _INTERVENTION_HOLD_EVIDENCE = f"{_RECORDED_HOLD}{GROUP_SEPARATOR}{Hold.INTERVENTION.value}"
246
+ _OWNERSHIP_HOLD_LABEL = "Ownership hold · taken-over roles"
247
+ _NO_OWNER_RECORDED = "not recorded"
248
+ _HAND_BACK_DETAIL = "Hand back each taken-over role to return it to automation."
249
+ _DEPENDENCY_HEADLINE = "Waiting on dependency"
250
+ _DEPENDENCY_UNAVAILABLE = "Dependency relationship evidence is unavailable."
251
+ _NO_UNFINISHED_DEPENDENCY = "No unfinished dependency is present in the current stored state."
252
+ _DEPENDENCIES_BEYOND_PAGE = (
253
+ "Additional blocking dependencies exist beyond the current bounded page."
254
+ )
255
+ _SLOT_HEADLINE = "Waiting for a slot"
256
+ _SLOT_DETAIL = "Starts when a slot frees."
257
+ _RESOURCES_HEADLINE = "Waiting on resources"
258
+ _RESOURCES_DETAIL = "Starts when the scheduler's resource gate admits it."
259
+ _RESOURCE_SAMPLE_DISCLAIMER = "The dashboard sample is separate from the scheduler's gate decision."
260
+ _QUOTA_HEADLINE = "Waiting on quota"
261
+ _QUOTA_REROUTED = "Moves to another agent first, as the wait exceeds max_quota_wait."
262
+ _QUOTA_WAITS_NO_OTHER_AGENT = "Waits for reset; no other agent is free to take it."
263
+ _QUOTA_ESCALATES_AT_LIMIT = "Escalates at max_quota_wait; no other agent is free to take it."
264
+ _QUOTA_WAITS_WITHIN_LIMIT = "Waits for reset; the block lifts within max_quota_wait."
265
+ _PR_HEADLINE = "Waiting on pull request"
266
+ _PR_NO_EVENT = "No recorded pull-request event is available."
267
+ _PR_CI_NOTE = "CI state is shown only when an engine observation recorded it."
268
+ _RESET_AT_FIELD = "resets_at"
269
+ _RECORDED_QUOTA_RESET = "Recorded quota reset"
270
+ _UNAVAILABLE = "unavailable"
271
+ StructuredEscalationEvidence = tuple[str, dict[ResolutionKind, str], tuple[str, ...]]
272
+
273
+
274
+ @dataclass(frozen=True)
275
+ class _StructuredEscalationCache:
276
+ packet: str | None
277
+ packet_error: str | None
278
+ evidence: StructuredEscalationEvidence
279
+
280
+
281
+ @dataclass(frozen=True)
282
+ class _RunningQuotaWait:
283
+ block: AgentBlock
284
+ budget_ends: datetime
285
+ quota: QuotaEvidenceView
286
+ raw: tuple[str, ...]
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class _BlockerParts:
291
+ headline: str
292
+ details: tuple[str, ...] = ()
293
+ notes: tuple[str, ...] = ()
294
+ related: tuple[TaskReferenceView, ...] = ()
295
+ raw: tuple[str, ...] = ()
296
+ quotas: tuple[QuotaEvidenceView, ...] = ()
297
+ observed: tuple[datetime, ...] = ()
298
+
299
+
300
+ @dataclass(frozen=True)
301
+ class _FoundBlocker:
302
+ kind: str
303
+ parts: _BlockerParts
304
+ block: AgentBlock | None = None
305
+
306
+
307
+ @dataclass(frozen=True)
308
+ class _QuotaWaits:
309
+ task_ids: frozenset[str]
310
+ waiting_by_agent: Counter[str]
311
+
312
+
313
+ class _VersionedCache[K, V]:
314
+ def __init__(self, state: StateReader) -> None:
315
+ self._state = state
316
+ self._version: int | None = None
317
+ self._values: dict[K, V] = {}
318
+
319
+ def get(self, key: K, load: Callable[[], V]) -> V:
320
+ version = self._state.data_version()
321
+ if version != self._version:
322
+ self._version = version
323
+ self._values = {}
324
+ if key not in self._values:
325
+ self._values[key] = load()
326
+ return self._values[key]
327
+
328
+
329
+ class DashboardSources(Protocol):
330
+ """External information composed into dashboard view models."""
331
+
332
+ async def repo_status(self) -> RepoStatus:
333
+ """Return the project checkout's git status."""
334
+ ...
335
+
336
+ def engine_up(self) -> bool:
337
+ """Return whether the engine holds its liveness lock."""
338
+ ...
339
+
340
+ def engine_identity(self) -> ProcessIdentity | None:
341
+ """Return the verified process identity of the engine lock holder."""
342
+ ...
343
+
344
+ async def resources(self) -> ResourceView:
345
+ """Return host and Shikiru process-tree resource readings."""
346
+ ...
347
+
348
+ async def containers(self) -> DockerView:
349
+ """Return the current Docker sample."""
350
+ ...
351
+
352
+ async def disk(self) -> DiskView:
353
+ """Return the current disk sample."""
354
+ ...
355
+
356
+ def resource_sample(self) -> ResourceView | None:
357
+ """Return the latest dashboard resource sample without taking a new one."""
358
+ ...
359
+
360
+
361
+ class StateDashboardViews:
362
+ """Build widget models from the current state and its injected data sources."""
363
+
364
+ def __init__(
365
+ self,
366
+ state: StateReader,
367
+ *,
368
+ focus_task: str | None,
369
+ settings: LiveSettings,
370
+ sources: DashboardSources,
371
+ queries: OperatorQueries,
372
+ clock: Clock,
373
+ motion: DashboardMotion,
374
+ work: TaskWorkService,
375
+ ) -> None:
376
+ """Use `state`, shared operator queries, sources and `clock` for widget data."""
377
+ self._state = state
378
+ self._focus_task = focus_task
379
+ self._settings = settings
380
+ self._sources = sources
381
+ self._queries = queries
382
+ self._clock = clock
383
+ self._motion = motion
384
+ self._work = work
385
+ self._usage_version = -1
386
+ self._resource_listeners: list[Callable[[], None]] = []
387
+ self._project_usage_cache: tuple[list[UsageView], dict[str, list[QuotaWindow]]] | None = (
388
+ None
389
+ )
390
+ self._project_usage_view_cache: RecordedUsageView | None = None
391
+ self._task_usage_cache: dict[str, list[UsageView]] = {}
392
+ self._task_cache: _VersionedCache[tuple[Status, ...] | None, list[Task]] = _VersionedCache(
393
+ state
394
+ )
395
+ self._browser_page_cache: _VersionedCache[tuple[object, ...], TaskBrowserPage] = (
396
+ _VersionedCache(state)
397
+ )
398
+ self._browser_pages_cache: _VersionedCache[
399
+ tuple[object, ...], tuple[TaskBrowserPage, ...]
400
+ ] = _VersionedCache(state)
401
+ self._structured_escalation_cache: dict[str, _StructuredEscalationCache] = {}
402
+ self._fault_version = -1
403
+ self._fault_cache: tuple[FaultStreak, ...] | None = None
404
+ self._tick_memo = TickMemo(state.data_version)
405
+
406
+ def _now(self) -> datetime:
407
+ return tick_now(self._clock)
408
+
409
+ def _cached_tasks(self, *, statuses: tuple[Status, ...] | None = None) -> list[Task]:
410
+ return list(
411
+ self._task_cache.get(statuses, lambda: self._state.list_tasks(statuses=statuses))
412
+ )
413
+
414
+ async def header(self) -> HeaderView:
415
+ """Build the project, engine, slot and repository summary."""
416
+ tasks = self._cached_tasks()
417
+ engine = self.engine_status()
418
+ repo = await self._sources.repo_status()
419
+ return HeaderView(
420
+ project=self._settings.current().project.name,
421
+ engine_up=engine.up,
422
+ occupied_slots=len(occupied_slot_ids(tasks)),
423
+ maximum_slots=self._settings.current().orchestration.max_parallel,
424
+ repo=repo,
425
+ alerts=self._alerts(),
426
+ integration_mode=self._settings.current().orchestration.integration,
427
+ engine_uptime_seconds=engine.uptime_seconds,
428
+ previous_engine_run_seconds=engine.previous_run_seconds,
429
+ publication=self._publication(repo),
430
+ )
431
+
432
+ def _publication(self, repo: RepoStatus) -> PublicationView:
433
+ target = None if repo.base is None else self._state.push_target(repo.base)
434
+ scheduled = None if target is None or not repo.remote_configured else target.scheduled_at
435
+ push_time = None if scheduled is None else to_local(scheduled).strftime(PUSH_TIME_FORMAT)
436
+ return PublicationView(
437
+ state=_publication_state(repo),
438
+ remote=self._settings.current().repo.remote,
439
+ unpushed=repo.unpushed,
440
+ push_time=push_time,
441
+ )
442
+
443
+ def engine_status(self) -> EngineStatusView:
444
+ """Return the lock liveness with the recorded duration of the live or previous run."""
445
+ engine_up = self.engine_up()
446
+ process_identity = self._sources.engine_identity()
447
+ identity = (
448
+ (process_identity.pid, process_identity.started_at)
449
+ if process_identity is not None and process_identity.started_at is not None
450
+ else None
451
+ )
452
+ uptime = project_engine_uptime(
453
+ self._state.latest_events(ENGINE_LIFECYCLE_EVENT_LIMIT, kinds=_ENGINE_LIFECYCLE_EVENTS),
454
+ now=self._now(),
455
+ running=engine_up,
456
+ identity=identity,
457
+ )
458
+ return EngineStatusView(
459
+ up=engine_up,
460
+ uptime_seconds=uptime.running_seconds,
461
+ previous_run_seconds=uptime.previous_run_seconds,
462
+ )
463
+
464
+ def project_usage(self) -> RecordedUsageView:
465
+ """Project recorded usage and provenance across every retained session lineage."""
466
+ self._refresh_usage_cache_version()
467
+ if self._project_usage_view_cache is None:
468
+ self._project_usage_view_cache = _recorded_usage(self._state.list_all_runs())
469
+ return self._project_usage_view_cache
470
+
471
+ def engine_up(self) -> bool:
472
+ """Return whether the current engine holds its liveness lock."""
473
+ return self._sources.engine_up()
474
+
475
+ def workspace_summary(self) -> WorkspaceSummaryView:
476
+ """Count home categories without treating them as disjoint task totals."""
477
+ return self._tick_memo.get("workspace_summary", self._build_workspace_summary)
478
+
479
+ def _build_workspace_summary(self) -> WorkspaceSummaryView:
480
+ attention = self.attention()
481
+ tasks = self._cached_tasks()
482
+ active_tasks = [
483
+ task for task in tasks if task.status not in (Status.DONE, Status.CANCELLED)
484
+ ]
485
+ now = self._now()
486
+ quota_wait_ids = {
487
+ task.id for task in tasks if self._running_quota_wait(task, now) is not None
488
+ }
489
+ running = sum(
490
+ task.status in _ACTIVE_TURN_STATUSES
491
+ and not task.held
492
+ and task.wait_reason is None
493
+ and task.id not in quota_wait_ids
494
+ for task in tasks
495
+ )
496
+ waiting = sum(
497
+ task.status not in (Status.DONE, Status.CANCELLED)
498
+ and (
499
+ task.status in (Status.QUEUED, Status.ESCALATED)
500
+ or task.held
501
+ or task.wait_reason is not None
502
+ or task.id in quota_wait_ids
503
+ )
504
+ for task in tasks
505
+ )
506
+ held_tasks = [task for task in active_tasks if task.held]
507
+ engine_up = self.engine_up()
508
+ operator_owned_roles = {
509
+ task.id: tuple(role.value for role in self._state.list_taken_over_roles(task.id))
510
+ for task in held_tasks
511
+ if Hold.INTERVENTION in task.holds
512
+ }
513
+ operator_owned = tuple(
514
+ f"{task.id} {role}"
515
+ for task in held_tasks
516
+ for role in operator_owned_roles.get(task.id, ())
517
+ )
518
+ held_task_states = tuple(
519
+ HeldTaskStateView(
520
+ task_id=task.id,
521
+ holds=tuple(sorted(hold.value for hold in task.holds)),
522
+ operator_owned_roles=operator_owned_roles.get(task.id, ()),
523
+ )
524
+ for task in held_tasks
525
+ )
526
+ return WorkspaceSummaryView(
527
+ attention_groups=len(attention.groups),
528
+ decisions=attention.decisions,
529
+ stalls=attention.stalls,
530
+ faults=attention.faults,
531
+ running=running,
532
+ waiting=waiting,
533
+ engine_up=engine_up,
534
+ held_tasks=len(held_tasks),
535
+ holds=tuple(sorted({hold.value for task in held_tasks for hold in task.holds})),
536
+ operator_owned=operator_owned,
537
+ held_task_states=held_task_states,
538
+ )
539
+
540
+ def _alerts(self) -> tuple[str, ...]:
541
+ lines = [
542
+ f"{GLYPH_STALLED} {fault.scope}{f' {fault.task_id}' if fault.task_id else ''}: "
543
+ f"{fault.error} x{fault.repeat} since {fault.first_seen.strftime(ALERT_TIME_FORMAT)}"
544
+ for fault in self._cached_faults()
545
+ ]
546
+ age = self._queries.oldest_escalation_age_seconds()
547
+ if age is not None:
548
+ lines.append(f"oldest escalation {format_age(age)}")
549
+ reload_error = self._settings.reload_error()
550
+ if reload_error is not None:
551
+ lines.append(f"settings not reloaded · {' · '.join(reload_error.splitlines())}")
552
+ return tuple(lines)
553
+
554
+ def _cached_faults(self) -> tuple[FaultStreak, ...]:
555
+ version = self._state.data_version()
556
+ if version != self._fault_version:
557
+ self._fault_version = version
558
+ self._fault_cache = tuple(self._queries.open_faults())
559
+ faults = self._fault_cache
560
+ if faults is None:
561
+ message = "fault cache was not initialized"
562
+ raise RuntimeError(message)
563
+ return faults
564
+
565
+ def strips(self) -> list[TaskStripData]:
566
+ """Build the active task strips, placing the focused task first."""
567
+ return list(self._tick_memo.get("strips", self._build_strips))
568
+
569
+ def _build_strips(self) -> list[TaskStripData]:
570
+ now = self._now()
571
+ tasks = self._cached_tasks(statuses=ACTIVE_STATUSES)
572
+ tasks.sort(key=lambda task: task.id != self._focus_task)
573
+ recent_merges = self._motion.recent_merges(now)
574
+ task_ids = {task.id for task in tasks}
575
+ all_tasks = [*tasks, *(task for task in recent_merges if task.id not in task_ids)]
576
+ rendered = self._motion.frames(tuple(all_tasks), now, engine_running=self.engine_up())
577
+ frames = {item.task_id: item for item in rendered.tasks}
578
+ return [
579
+ self._strip_with_motion(task, frames[task.id], self._motion.current_cost(task))
580
+ for task in all_tasks
581
+ ]
582
+
583
+ def queue(self) -> list[QueueRow]:
584
+ """Build queued-task rows in dispatch order."""
585
+ tasks = self._cached_tasks(statuses=(Status.QUEUED,))
586
+ tasks.sort(key=dispatch_sort_key)
587
+ return [
588
+ QueueRow(
589
+ task_id=task.id,
590
+ title=task.title,
591
+ priority=task.priority,
592
+ dependencies=tuple(task.depends_on),
593
+ wait_reason=task.wait_reason,
594
+ )
595
+ for task in tasks
596
+ ]
597
+
598
+ def task_browser(
599
+ self,
600
+ *,
601
+ task_filter: TaskBrowserFilter,
602
+ search: str,
603
+ offset: int,
604
+ limit: int,
605
+ selected_task_id: str | None,
606
+ ) -> TaskBrowserView:
607
+ """Build one ready-to-render browser page and retain the selected task context."""
608
+ now = self._now()
609
+ (
610
+ motion_task_ids,
611
+ stalled_ids,
612
+ diagnosed_stall_task_ids,
613
+ agent_processing_ids,
614
+ ) = self._task_browser_motion(task_filter, now)
615
+ quota_wait_task_ids = (
616
+ self._quota_wait_task_ids(now) if task_filter is TaskBrowserFilter.WAITING else ()
617
+ )
618
+ page = self._browser_page_cache.get(
619
+ (
620
+ task_filter,
621
+ search,
622
+ limit,
623
+ offset,
624
+ diagnosed_stall_task_ids,
625
+ quota_wait_task_ids,
626
+ ),
627
+ lambda: self._state.task_browser_page(
628
+ category=task_filter.value,
629
+ search=search,
630
+ limit=limit,
631
+ offset=offset,
632
+ diagnosed_stall_task_ids=diagnosed_stall_task_ids,
633
+ quota_wait_task_ids=quota_wait_task_ids,
634
+ ),
635
+ )
636
+ selected_task = self._selected_browser_task((page,), selected_task_id)
637
+ return self._task_browser_view(
638
+ page,
639
+ task_filter=task_filter,
640
+ search=search,
641
+ offset=offset,
642
+ selected_task_id=selected_task_id,
643
+ selected_task=selected_task,
644
+ now=now,
645
+ motion_task_ids=motion_task_ids,
646
+ stalled_ids=stalled_ids,
647
+ agent_processing_ids=agent_processing_ids,
648
+ )
649
+
650
+ @property
651
+ def recent_task_limit(self) -> int:
652
+ """Return how many most recent terminal tasks Home Recent can page through."""
653
+ return self._settings.current().dashboard.recent_task_limit
654
+
655
+ @property
656
+ def recent_task_window(self) -> int:
657
+ """Return how many Recent tasks Home shows at once."""
658
+ return self._settings.current().dashboard.recent_task_window
659
+
660
+ def task_browser_pages(
661
+ self,
662
+ *,
663
+ task_filter: TaskBrowserFilter,
664
+ search: str,
665
+ limit: int,
666
+ page_count: int,
667
+ selected_task_id: str | None,
668
+ ) -> tuple[TaskBrowserView, ...]:
669
+ """Build contiguous bounded browser pages from one state snapshot."""
670
+ now = self._now()
671
+ (
672
+ motion_task_ids,
673
+ stalled_ids,
674
+ diagnosed_stall_task_ids,
675
+ agent_processing_ids,
676
+ ) = self._task_browser_motion(task_filter, now)
677
+ quota_wait_task_ids = (
678
+ self._quota_wait_task_ids(now) if task_filter is TaskBrowserFilter.WAITING else ()
679
+ )
680
+ pages = self._browser_pages_cache.get(
681
+ (
682
+ task_filter,
683
+ search,
684
+ limit,
685
+ page_count,
686
+ diagnosed_stall_task_ids,
687
+ quota_wait_task_ids,
688
+ ),
689
+ lambda: self._state.task_browser_pages(
690
+ category=task_filter.value,
691
+ search=search,
692
+ limit=limit,
693
+ page_count=page_count,
694
+ diagnosed_stall_task_ids=diagnosed_stall_task_ids,
695
+ quota_wait_task_ids=quota_wait_task_ids,
696
+ ),
697
+ )
698
+ selected_task = self._selected_browser_task(pages, selected_task_id)
699
+ return tuple(
700
+ self._task_browser_view(
701
+ page,
702
+ task_filter=task_filter,
703
+ search=search,
704
+ offset=page_index * limit,
705
+ selected_task_id=selected_task_id,
706
+ selected_task=selected_task,
707
+ now=now,
708
+ motion_task_ids=motion_task_ids,
709
+ stalled_ids=stalled_ids,
710
+ agent_processing_ids=agent_processing_ids,
711
+ )
712
+ for page_index, page in enumerate(pages)
713
+ )
714
+
715
+ def _task_browser_motion(
716
+ self,
717
+ task_filter: TaskBrowserFilter,
718
+ now: datetime,
719
+ ) -> tuple[set[str], set[str], tuple[str, ...], set[str]]:
720
+ motion_task_ids: set[str] = set()
721
+ stalled_ids: set[str] = set()
722
+ agent_processing_ids: set[str] = set()
723
+ if task_filter in _MOTION_FILTERS:
724
+ current_turn_tasks = self._cached_tasks(statuses=_ACTIVE_TURN_STATUSES)
725
+ motion_task_ids.update(task.id for task in current_turn_tasks)
726
+ frames = self._active_turn_frames(now, engine_running=self.engine_up())
727
+ stalled_ids.update(item.task_id for item in frames.tasks if item.stalled)
728
+ agent_processing_ids.update(
729
+ item.task_id for item in frames.tasks if item.agent_processing
730
+ )
731
+ return motion_task_ids, stalled_ids, tuple(sorted(stalled_ids)), agent_processing_ids
732
+
733
+ def _active_turn_frames(self, now: datetime, *, engine_running: bool) -> MotionFrame:
734
+ return self._tick_memo.get(
735
+ ("active_turn_frames", engine_running),
736
+ lambda: self._motion.frames(
737
+ tuple(self._cached_tasks(statuses=_ACTIVE_TURN_STATUSES)),
738
+ now,
739
+ engine_running=engine_running,
740
+ ),
741
+ )
742
+
743
+ def _quota_wait_task_ids(self, now: datetime) -> tuple[str, ...]:
744
+ return tuple(
745
+ task.id
746
+ for task in self._cached_tasks(statuses=_ACTIVE_TURN_STATUSES)
747
+ if self._running_quota_wait(task, now) is not None
748
+ )
749
+
750
+ def _quota_waits(self, now: datetime) -> _QuotaWaits:
751
+ return self._tick_memo.get("quota_waits", lambda: self._build_quota_waits(now))
752
+
753
+ def _build_quota_waits(self, now: datetime) -> _QuotaWaits:
754
+ blocked_until = {block.agent: block.until for block in self._active_blocks(now)}
755
+ settings = self._settings.current()
756
+ task_ids: set[str] = set()
757
+ waiting: Counter[str] = Counter()
758
+ for task in self._cached_tasks(statuses=_ACTIVE_TURN_STATUSES):
759
+ running_wait = self._running_quota_wait(task, now)
760
+ if running_wait is not None:
761
+ task_ids.add(task.id)
762
+ waiting[running_wait.block.agent] += 1
763
+ for task in self._cached_tasks(statuses=(Status.QUEUED,)):
764
+ if task.wait_reason == WaitReason.QUOTA.value:
765
+ task_ids.add(task.id)
766
+ waiting.update(quota_blocked_agents(task_agents(task, settings), blocked_until))
767
+ return _QuotaWaits(task_ids=frozenset(task_ids), waiting_by_agent=waiting)
768
+
769
+ def _queued_quota_block(self, task: Task, now: datetime) -> AgentBlock | None:
770
+ if task.status is not Status.QUEUED or task.wait_reason != WaitReason.QUOTA.value:
771
+ return None
772
+ blocks = {block.agent: block for block in self._active_blocks(now)}
773
+ agent = quota_blocking_agent(
774
+ task_agents(task, self._settings.current()),
775
+ {name: block.until for name, block in blocks.items()},
776
+ )
777
+ return None if agent is None else blocks[agent]
778
+
779
+ def _active_blocks(self, now: datetime) -> list[AgentBlock]:
780
+ return self._tick_memo.get("active_blocks", lambda: self._state.active_agent_blocks(now))
781
+
782
+ def _selected_browser_task(
783
+ self,
784
+ pages: Sequence[TaskBrowserPage],
785
+ selected_task_id: str | None,
786
+ ) -> Task | None:
787
+ if selected_task_id is None:
788
+ return None
789
+ selected_task = next(
790
+ (task for page in pages for task in page.tasks if task.id == selected_task_id),
791
+ None,
792
+ )
793
+ if selected_task is None:
794
+ selected_task = self._state.get_task(selected_task_id)
795
+ return selected_task
796
+
797
+ def _task_browser_view(
798
+ self,
799
+ page: TaskBrowserPage,
800
+ *,
801
+ task_filter: TaskBrowserFilter,
802
+ search: str,
803
+ offset: int,
804
+ selected_task_id: str | None,
805
+ selected_task: Task | None,
806
+ now: datetime,
807
+ motion_task_ids: set[str],
808
+ stalled_ids: set[str],
809
+ agent_processing_ids: set[str],
810
+ ) -> TaskBrowserView:
811
+ page_stalled_ids = set(stalled_ids)
812
+ page_agent_processing_ids = set(agent_processing_ids)
813
+
814
+ framed_tasks = [
815
+ task
816
+ for task in page.tasks
817
+ if task.status in ACTIVE_STATUSES and task.id not in motion_task_ids
818
+ ]
819
+ if (
820
+ selected_task is not None
821
+ and selected_task.status in ACTIVE_STATUSES
822
+ and selected_task.id not in motion_task_ids
823
+ and selected_task.id not in {task.id for task in framed_tasks}
824
+ ):
825
+ framed_tasks.append(selected_task)
826
+ if framed_tasks:
827
+ frames = self._motion.frames(tuple(framed_tasks), now, engine_running=self.engine_up())
828
+ page_stalled_ids.update(item.task_id for item in frames.tasks if item.stalled)
829
+ page_agent_processing_ids.update(
830
+ item.task_id for item in frames.tasks if item.agent_processing
831
+ )
832
+ open_decision_ids = page.open_decision_task_ids
833
+ quota_wait_ids = self._quota_waits(now).task_ids
834
+ rows = [
835
+ self._task_browser_row(
836
+ task,
837
+ open_decision=task.id in open_decision_ids,
838
+ diagnosed_stall=task.id in page_stalled_ids,
839
+ agent_processing_ids=page_agent_processing_ids,
840
+ quota_wait=task.id in quota_wait_ids,
841
+ now=now,
842
+ task_filter=task_filter,
843
+ )
844
+ for task in page.tasks
845
+ ]
846
+ if task_filter is TaskBrowserFilter.ACTIVE:
847
+ rows.sort(key=lambda row: not (row.open_decision or row.diagnosed_stall))
848
+ selected_row = next(
849
+ (row for row in rows if row.task_id == selected_task_id),
850
+ None,
851
+ )
852
+ if selected_row is None and selected_task is not None:
853
+ selected_row = self._task_browser_row(
854
+ selected_task,
855
+ open_decision=selected_task.status is Status.ESCALATED,
856
+ diagnosed_stall=selected_task.id in page_stalled_ids,
857
+ agent_processing_ids=page_agent_processing_ids,
858
+ quota_wait=selected_task.id in quota_wait_ids,
859
+ now=now,
860
+ task_filter=task_filter,
861
+ )
862
+ return TaskBrowserView(
863
+ filter=task_filter,
864
+ search=search,
865
+ rows=tuple(rows),
866
+ selected_task=selected_row,
867
+ matching_count=page.matching_count,
868
+ filtered_count=page.filtered_count,
869
+ project_count=page.project_count,
870
+ has_more=offset + len(rows) < page.matching_count,
871
+ )
872
+
873
+ def _task_browser_row(
874
+ self,
875
+ task: Task,
876
+ *,
877
+ open_decision: bool,
878
+ diagnosed_stall: bool,
879
+ agent_processing_ids: set[str],
880
+ quota_wait: bool,
881
+ now: datetime,
882
+ task_filter: TaskBrowserFilter,
883
+ ) -> TaskBrowserRow:
884
+ role = ACTING_ROLE.get(task.step) if task.step is not None else None
885
+ group_label = None
886
+ if task_filter is TaskBrowserFilter.WAITING:
887
+ group_label = "Queued" if task.status is Status.QUEUED else "Started waiting"
888
+ return TaskBrowserRow(
889
+ task_id=task.id,
890
+ title=task.title,
891
+ status=task.status.value,
892
+ step=task.step.value if task.step is not None else None,
893
+ iteration=task.iteration,
894
+ acting_role=role.value if role is not None else None,
895
+ priority=task.priority,
896
+ dependencies=tuple(task.depends_on),
897
+ wait_reason=task.wait_reason,
898
+ holds=tuple(sorted(hold.value for hold in task.holds)),
899
+ age=_age(now - task.created_at),
900
+ group_label=group_label,
901
+ open_decision=open_decision,
902
+ diagnosed_stall=diagnosed_stall,
903
+ requeued_age=_age(now - task.requeued_at) if task.requeued_at is not None else None,
904
+ agent_processing=(
905
+ task.id in agent_processing_ids and not open_decision and not diagnosed_stall
906
+ ),
907
+ quota_wait=quota_wait,
908
+ )
909
+
910
+ def escalations(self) -> list[EscalationPanelView]:
911
+ """Build every durable open decision, retaining its row when evidence is unreadable."""
912
+ decisions = self._decision_views(self._now(), self._queries.list_escalations())
913
+ return list(decisions.values())
914
+
915
+ def _decision_views(
916
+ self, now: datetime, items: list[EscalationView]
917
+ ) -> dict[str, EscalationPanelView]:
918
+ open_ids = {item.escalation.id for item in items}
919
+ self._structured_escalation_cache = {
920
+ escalation_id: cached
921
+ for escalation_id, cached in self._structured_escalation_cache.items()
922
+ if escalation_id in open_ids
923
+ }
924
+ pulse = self._motion.frames((), now, escalations_open=bool(items)).escalation_red
925
+ tasks = {task.id: task for task in self._cached_tasks()}
926
+ result: dict[str, EscalationPanelView] = {}
927
+ for item in items:
928
+ escalation = item.escalation
929
+ question, consequences, evidence_errors = self._structured_escalation(item)
930
+ task = tasks.get(escalation.task_id)
931
+ result[escalation.id] = EscalationPanelView(
932
+ escalation_id=escalation.id,
933
+ task_id=escalation.task_id,
934
+ headline=escalation.headline,
935
+ question=question,
936
+ options=tuple(
937
+ f"`{option.label.value}`{' ★' if option.recommended else ''}"
938
+ + (f": {consequences[option.label]}" if option.label in consequences else "")
939
+ for option in escalation.options
940
+ ),
941
+ packet=item.packet if item.packet is not None else "",
942
+ pulse_red=pulse,
943
+ task_title=task.title if task is not None else escalation.task_id,
944
+ age=_age(now - escalation.created_at),
945
+ resolution_actions=tuple(option.label.value for option in escalation.options),
946
+ answer_available=any(
947
+ option.label is ResolutionKind.ANSWER for option in escalation.options
948
+ ),
949
+ evidence_error="; ".join(evidence_errors) if evidence_errors else None,
950
+ reason=escalation.reason,
951
+ )
952
+ return result
953
+
954
+ def _structured_escalation(
955
+ self,
956
+ item: EscalationView,
957
+ ) -> tuple[str, dict[ResolutionKind, str], tuple[str, ...]]:
958
+ escalation_id = item.escalation.id
959
+ cached = self._structured_escalation_cache.get(escalation_id)
960
+ if (
961
+ cached is not None
962
+ and cached.packet == item.packet
963
+ and cached.packet_error == item.packet_error
964
+ ):
965
+ return cached.evidence
966
+ question = ""
967
+ consequences: dict[ResolutionKind, str] = {}
968
+ errors = [item.packet_error] if item.packet_error is not None else []
969
+ if item.packet is not None:
970
+ packet = parse_escalation_packet(item.packet)
971
+ errors.extend(packet.errors)
972
+ if packet.agent_request is not None:
973
+ question = packet.agent_request.question
974
+ elif packet.question is not None:
975
+ question = packet.question
976
+ consequences = {
977
+ resolution.kind: resolution.consequences for resolution in packet.resolutions
978
+ }
979
+ cached = question, consequences, tuple(errors)
980
+ self._structured_escalation_cache[escalation_id] = _StructuredEscalationCache(
981
+ packet=item.packet,
982
+ packet_error=item.packet_error,
983
+ evidence=cached,
984
+ )
985
+ return cached
986
+
987
+ def _dialogue_view(self, task_id: str | None = None) -> list[DialogueBubbleView]:
988
+ now = self._now()
989
+ return [
990
+ DialogueBubbleView(
991
+ role=_event_role(event.kind, event.data),
992
+ text=_event_text(event.kind, event.message, event.data),
993
+ task_id=event.task_id or "",
994
+ age=_age(now - event.ts),
995
+ )
996
+ for event in self._state.latest_events(
997
+ DIALOGUE_LINE_LIMIT,
998
+ kinds=_ACTIVITY_KINDS,
999
+ implementer_turns_only=True,
1000
+ task_id=task_id,
1001
+ )
1002
+ ]
1003
+
1004
+ def attention(self) -> AttentionView:
1005
+ """Group durable decisions, existing stall diagnoses and open fault streaks by task."""
1006
+ return self._tick_memo.get("attention", self._build_attention)
1007
+
1008
+ def _build_attention(self) -> AttentionView:
1009
+ now = self._now()
1010
+ tasks = {task.id: task for task in self._cached_tasks()}
1011
+ escalations = self._queries.list_escalations()
1012
+ decision_views = self._decision_views(now, escalations)
1013
+ group_reasons: dict[str | None, list[str]] = {}
1014
+ group_decisions: dict[str | None, list[EscalationPanelView]] = {}
1015
+ decision_order: dict[str | None, tuple[datetime, str]] = {}
1016
+ group_titles: dict[str | None, str] = {}
1017
+
1018
+ for item in escalations:
1019
+ task_id = item.escalation.task_id
1020
+ group_decisions.setdefault(task_id, []).append(decision_views[item.escalation.id])
1021
+ task = tasks.get(task_id)
1022
+ group_titles[task_id] = task.title if task is not None else task_id
1023
+ order = (item.escalation.created_at, item.escalation.id)
1024
+ if task_id not in decision_order or order < decision_order[task_id]:
1025
+ decision_order[task_id] = order
1026
+
1027
+ frames = self._active_turn_frames(now, engine_running=True)
1028
+ stalled_ids = {item.task_id for item in frames.tasks if item.stalled}
1029
+ for task_id in stalled_ids:
1030
+ group_reasons.setdefault(task_id, []).append("diagnosed stall")
1031
+ group_titles[task_id] = tasks[task_id].title
1032
+
1033
+ faults = self._cached_faults()
1034
+ for fault in faults:
1035
+ task_id = fault.task_id
1036
+ reason = f"engine fault · {fault.scope}: {fault.error} x{fault.repeat}"
1037
+ if task_id is None:
1038
+ group_titles[None] = "Project"
1039
+ else:
1040
+ task = tasks.get(task_id)
1041
+ group_titles[task_id] = task.title if task is not None else task_id
1042
+ group_reasons.setdefault(task_id, []).append(reason)
1043
+
1044
+ group_ids = set(group_reasons) | set(group_decisions)
1045
+ decision_groups = sorted(
1046
+ (task_id for task_id in group_ids if task_id in decision_order),
1047
+ key=lambda task_id: decision_order[task_id],
1048
+ )
1049
+ other_groups = sorted(
1050
+ (task_id for task_id in group_ids if task_id not in decision_order),
1051
+ key=lambda task_id: (task_id is None, task_id or ""),
1052
+ )
1053
+ entries = tuple(
1054
+ AttentionGroupView(
1055
+ task_id=task_id,
1056
+ title=group_titles[task_id],
1057
+ reasons=tuple(group_reasons.get(task_id, ())),
1058
+ escalations=tuple(group_decisions.get(task_id, ())),
1059
+ )
1060
+ for task_id in (*decision_groups, *other_groups)
1061
+ )
1062
+ return AttentionView(
1063
+ groups=entries,
1064
+ decisions=len(decision_views),
1065
+ stalls=len(stalled_ids),
1066
+ faults=len(faults),
1067
+ )
1068
+
1069
+ def engine_faults(self) -> tuple[EngineFaultView, ...]:
1070
+ """Return open fault cards in stable order, those needing the Operator first."""
1071
+ settings = self._settings.current().orchestration
1072
+ policy = RetryPolicy(
1073
+ backoff_initial_seconds=settings.faults.backoff_initial_seconds,
1074
+ backoff_max_seconds=settings.faults.backoff_max_seconds,
1075
+ close_attempts=settings.network.pr_close_attempts,
1076
+ )
1077
+ log_path = self._queries.engine_log_location()
1078
+ state_path = self._queries.state_database_location()
1079
+ ordered = sorted(self._cached_faults(), key=lambda fault: fault.first_seen)
1080
+ views = [
1081
+ engine_fault_view(fault, policy, log_path=log_path, state_path=state_path)
1082
+ for fault in ordered
1083
+ ]
1084
+ return tuple(sorted(views, key=lambda view: not view.needs_action))
1085
+
1086
+ async def live_work(self, task_id: str) -> TaskWorkSample | None:
1087
+ """Sample the live work of a task whose Results still change, or None when there is none."""
1088
+ task = self._state.get_task(task_id)
1089
+ if task is None or task.repo is None or not tracks_live_work(task.status):
1090
+ return None
1091
+ return await self._work.sample(
1092
+ task.id, self._queries.task_worktree_path(task.id), task.repo.base_ref
1093
+ )
1094
+
1095
+ async def task_detail(
1096
+ self,
1097
+ task_id: str,
1098
+ *,
1099
+ relationship_offset: int = 0,
1100
+ ) -> TaskDetailView | None:
1101
+ """Project current task state and its bounded, recorded evidence for the detail view."""
1102
+ detail = await self._queries.get_task_detail(task_id)
1103
+ if detail is None:
1104
+ return None
1105
+ live = await self.live_work(task_id)
1106
+ try:
1107
+ signal_page = await self.task_timeline(
1108
+ task_id,
1109
+ cursor=None,
1110
+ role=None,
1111
+ turn=None,
1112
+ category=TaskTimelineCategory.SIGNAL.value,
1113
+ )
1114
+ except (ShikiruError, OSError, ValueError, TypeError) as error:
1115
+ signal_page = _timeline_read_error(error, role=None, turn=None)
1116
+ if signal_page is None:
1117
+ return None
1118
+ latest_signal = _latest_signal_observation(signal_page)
1119
+ task = detail.task
1120
+ now = self._now()
1121
+ triage = next(
1122
+ (item.data for item in reversed(detail.reports) if isinstance(item.data, TriageReport)),
1123
+ None,
1124
+ )
1125
+ verdict = next(
1126
+ (
1127
+ _task_report(item)
1128
+ for item in reversed(detail.reports)
1129
+ if isinstance(item.data, Verdict)
1130
+ ),
1131
+ None,
1132
+ )
1133
+ latest_report = next(
1134
+ (
1135
+ _task_report(item)
1136
+ for item in reversed(detail.reports)
1137
+ if item.source == AGENT_REPORT_SOURCE
1138
+ ),
1139
+ None,
1140
+ )
1141
+ ownership = tuple(
1142
+ run.role.value for run in detail.sessions if run.status is RunStatus.TAKEN_OVER
1143
+ )
1144
+ try:
1145
+ history_events = self._state.task_timeline_events(
1146
+ task_id,
1147
+ limit=TASK_MODEL_HISTORY_EVENT_LIMIT,
1148
+ kinds=TASK_MODEL_HISTORY_EVENTS,
1149
+ )
1150
+ history_error = False
1151
+ except ShikiruError, OSError, ValueError:
1152
+ history_events = []
1153
+ history_error = True
1154
+ stored_relationships = self._state.task_relationships(
1155
+ task.id,
1156
+ offset=relationship_offset,
1157
+ )
1158
+ relationships = (
1159
+ _task_relationships_view(stored_relationships)
1160
+ if stored_relationships is not None
1161
+ else None
1162
+ )
1163
+ task_agent_usage = tuple(self.usage(task_id))
1164
+ task_frame = self._motion.frames((task,), now, engine_running=self.engine_up()).tasks[0]
1165
+ latest_verdict = next(
1166
+ (item.data for item in reversed(detail.reports) if isinstance(item.data, Verdict)),
1167
+ None,
1168
+ )
1169
+ implementation = next(
1170
+ (
1171
+ item.data
1172
+ for item in reversed(detail.reports)
1173
+ if item.source == AGENT_REPORT_SOURCE
1174
+ and isinstance(item.data, ImplementationReport)
1175
+ ),
1176
+ None,
1177
+ )
1178
+ workflow = self._task_workflow(task, detail.events, detail.reports)
1179
+ pull_request = self._pull_request_line(task.id, task_done=task.status is Status.DONE)
1180
+ reported = reported_tests(implementation.tests) if implementation is not None else None
1181
+ blockers, blocker_evidence = self._task_blockers(task, relationships, ownership, now)
1182
+ return TaskDetailView(
1183
+ task_id=task.id,
1184
+ title=task.title,
1185
+ status=task.status.value,
1186
+ status_reason=task.status_reason.value if task.status_reason is not None else None,
1187
+ mode=task.mode.value if task.mode is not None else None,
1188
+ complexity=task.complexity,
1189
+ step=_step_label(task.status, task.step, task.iteration),
1190
+ acting_role=task_acting_role(task).value,
1191
+ iteration=task.iteration,
1192
+ holds=tuple(sorted(hold.value for hold in task.holds)),
1193
+ wait_reason=task.wait_reason,
1194
+ ownership=ownership,
1195
+ request=task.request,
1196
+ acceptance_criteria=_acceptance_criteria(triage, task.request),
1197
+ progress=self._strip_with_motion(task, task_frame, self._motion.current_cost(task)),
1198
+ latest_report=latest_report,
1199
+ latest_verdict=verdict,
1200
+ branch=task.repo.branch if task.repo is not None else None,
1201
+ worktree_path=str(self._queries.task_worktree_path(task.id)),
1202
+ window_id=task.window_id,
1203
+ sessions=self._with_context_levels(
1204
+ project_task_sessions(
1205
+ detail.sessions,
1206
+ history_events,
1207
+ history_truncated=len(history_events) == TASK_MODEL_HISTORY_EVENT_LIMIT,
1208
+ history_error=history_error,
1209
+ )
1210
+ ),
1211
+ relationships=relationships,
1212
+ blockers=blockers,
1213
+ blocker_evidence=blocker_evidence,
1214
+ submitted_age=_age(now - task.created_at),
1215
+ requeued_age=_age(now - task.requeued_at) if task.requeued_at is not None else None,
1216
+ usage=_recorded_usage(detail.sessions),
1217
+ signal_observed=task_frame.signal_observed,
1218
+ agent_usage=task_agent_usage,
1219
+ priority=task.priority,
1220
+ actual_stage=task.step.value if task.step is not None else None,
1221
+ actual_stage_started_at=task_frame.actual_step_started_at,
1222
+ stage_ended_at=task.finished_at,
1223
+ milestones=_task_milestone_views(workflow),
1224
+ latest_signal=(latest_signal.summary if latest_signal is not None else None),
1225
+ latest_signal_at=(
1226
+ _timeline_timestamp(latest_signal.timestamp)
1227
+ if latest_signal is not None and latest_signal.timestamp is not None
1228
+ else None
1229
+ ),
1230
+ max_iterations=self._settings.current().orchestration.max_iterations,
1231
+ started_at=task.started_at,
1232
+ escalation_remaining=escalation_remaining(
1233
+ task.counters.rejections_on_rung,
1234
+ self._settings.current().roles.implementer.escalate_after,
1235
+ can_escalate=move_up(task, Role.IMPLEMENTER, self._settings.current()) is not None,
1236
+ ),
1237
+ verdict_counts=(verdict_counts(latest_verdict) if latest_verdict is not None else None),
1238
+ last_words=task_frame.last_words,
1239
+ results=self._task_results(
1240
+ task,
1241
+ detail,
1242
+ reported,
1243
+ pull_request,
1244
+ verdict_risks=_current_verdict_risks(task, detail.reports),
1245
+ live=live,
1246
+ ),
1247
+ decisions=self._task_decisions(task.id),
1248
+ can_take_over=self._can_take_over(task),
1249
+ )
1250
+
1251
+ def _can_take_over(self, task: Task) -> bool:
1252
+ availability = self._queries.task_action_availability(task.id, task_acting_role(task))
1253
+ return any(
1254
+ item.action is OperatorTaskAction.TAKE_OVER and item.enabled for item in availability
1255
+ )
1256
+
1257
+ async def task_timeline(
1258
+ self,
1259
+ task_id: str,
1260
+ *,
1261
+ cursor: str | None,
1262
+ role: str | None,
1263
+ turn: str | None,
1264
+ category: str,
1265
+ ) -> TaskTimelinePageView | None:
1266
+ """Return the task's bounded normalized evidence page without loading other files."""
1267
+ timeline = await self._queries.task_timeline(
1268
+ task_id,
1269
+ limit=self._settings.current().dashboard.timeline_page_size,
1270
+ cursor=cursor,
1271
+ role=role,
1272
+ turn=turn,
1273
+ category=category,
1274
+ scan_limit=self._settings.current().dashboard.timeline_scan_rows,
1275
+ )
1276
+ if timeline is None:
1277
+ return None
1278
+ return TaskTimelinePageView(
1279
+ entries=tuple(
1280
+ TaskTimelineEntryView(
1281
+ identity=item.identity,
1282
+ timestamp=item.timestamp,
1283
+ source=item.source,
1284
+ category=item.category,
1285
+ summary=item.summary,
1286
+ phrase=item.phrase,
1287
+ glyph=item.glyph,
1288
+ tone=item.tone,
1289
+ detail=item.detail,
1290
+ role=item.role,
1291
+ turn=item.turn,
1292
+ )
1293
+ for item in timeline.entries
1294
+ ),
1295
+ older_cursor=timeline.older_cursor,
1296
+ has_older=timeline.has_older,
1297
+ )
1298
+
1299
+ def _with_context_levels(
1300
+ self, sessions: tuple[TaskSessionView, ...]
1301
+ ) -> tuple[TaskSessionView, ...]:
1302
+ titles = self._settings.current().dashboard.titles
1303
+ return tuple(
1304
+ replace(
1305
+ session,
1306
+ context_level=usage_level(
1307
+ session.context_used_pct,
1308
+ warn_pct=titles.context_warn_pct,
1309
+ alert_pct=titles.context_alert_pct,
1310
+ ),
1311
+ )
1312
+ if session.context_used_pct is not None
1313
+ else session
1314
+ for session in sessions
1315
+ )
1316
+
1317
+ def _task_blockers(
1318
+ self,
1319
+ task: Task,
1320
+ relationships: TaskRelationshipsView | None,
1321
+ ownership: tuple[str, ...],
1322
+ now: datetime,
1323
+ ) -> tuple[tuple[TaskBlockerView, ...], BlockerEvidenceView | None]:
1324
+ found = self._found_blockers(task, relationships, ownership, now)
1325
+ if not found:
1326
+ return (), None
1327
+ recorded = (
1328
+ (f"{_RECORDED_REASON}{GROUP_SEPARATOR}{task.wait_reason}",) if task.wait_reason else ()
1329
+ )
1330
+ blockers = tuple(
1331
+ TaskBlockerView(
1332
+ kind=item.kind,
1333
+ headline=item.parts.headline,
1334
+ details=item.parts.details,
1335
+ related_tasks=item.parts.related,
1336
+ quota_wait=None if item.block is None else self._quota_wait_view(item.block, now),
1337
+ )
1338
+ for item in found
1339
+ )
1340
+ evidence = BlockerEvidenceView(
1341
+ quotas=tuple(quota for item in found for quota in item.parts.quotas),
1342
+ observed_at=tuple(instant for item in found for instant in item.parts.observed),
1343
+ notes=tuple(note for item in found for note in item.parts.notes),
1344
+ raw=(*recorded, *(line for item in found for line in item.parts.raw)),
1345
+ )
1346
+ has_evidence = bool(
1347
+ evidence.quotas or evidence.observed_at or evidence.notes or evidence.raw
1348
+ )
1349
+ return blockers, evidence if has_evidence else None
1350
+
1351
+ def _found_blockers(
1352
+ self,
1353
+ task: Task,
1354
+ relationships: TaskRelationshipsView | None,
1355
+ ownership: tuple[str, ...],
1356
+ now: datetime,
1357
+ ) -> tuple[_FoundBlocker, ...]:
1358
+ wait_reason = task.wait_reason
1359
+ found: list[_FoundBlocker] = []
1360
+ quota = self._quota_blocker(task, now)
1361
+ if quota is not None:
1362
+ found.append(quota)
1363
+ elif wait_reason not in (None, WaitReason.PAUSED.value):
1364
+ found.append(
1365
+ _FoundBlocker(wait_reason, self._reason_parts(task, wait_reason, relationships))
1366
+ )
1367
+ if Hold.PAUSED in task.holds or wait_reason == WaitReason.PAUSED.value:
1368
+ raw = (_PAUSE_HOLD_EVIDENCE,) if Hold.PAUSED in task.holds else ()
1369
+ found.append(
1370
+ _FoundBlocker(
1371
+ Hold.PAUSED.value,
1372
+ _BlockerParts(_PAUSED_HEADLINE, (_PAUSE_HOLD_DETAIL,), raw=raw),
1373
+ )
1374
+ )
1375
+ if Hold.INTERVENTION in task.holds:
1376
+ found.append(_FoundBlocker(Hold.INTERVENTION.value, _intervention_parts(ownership)))
1377
+ return tuple(found)
1378
+
1379
+ def _quota_blocker(self, task: Task, now: datetime) -> _FoundBlocker | None:
1380
+ if task.wait_reason == WaitReason.QUOTA.value:
1381
+ quota_block = self._queued_quota_block(task, now)
1382
+ parts = self._queued_quota_parts(task, quota_block, now)
1383
+ return _FoundBlocker(WaitReason.QUOTA.value, parts, quota_block)
1384
+ if task.wait_reason is not None:
1385
+ return None
1386
+ running_wait = self._running_quota_wait(task, now)
1387
+ if running_wait is None:
1388
+ return None
1389
+ parts = self._running_quota_parts(task, running_wait, now)
1390
+ return _FoundBlocker(WaitReason.QUOTA.value, parts, running_wait.block)
1391
+
1392
+ def _quota_wait_view(self, quota_block: AgentBlock, now: datetime) -> QuotaWaitView:
1393
+ max_wait = timedelta(seconds=self._settings.current().orchestration.timeouts.max_quota_wait)
1394
+ changes = (
1395
+ quota_block.until,
1396
+ quota_block.until - max_wait,
1397
+ *(block.until for block in self._active_blocks(now)),
1398
+ )
1399
+ return QuotaWaitView(
1400
+ agent=quota_block.agent,
1401
+ until=quota_block.until,
1402
+ refresh_at=next_quota_reload(now, changes),
1403
+ )
1404
+
1405
+ def _reason_parts(
1406
+ self,
1407
+ task: Task,
1408
+ wait_reason: str,
1409
+ relationships: TaskRelationshipsView | None,
1410
+ ) -> _BlockerParts:
1411
+ match wait_reason:
1412
+ case WaitReason.DEPENDENCY.value:
1413
+ return self._dependency_parts(relationships)
1414
+ case WaitReason.SLOT.value:
1415
+ return self._slot_parts()
1416
+ case WaitReason.RESOURCES.value:
1417
+ return self._resource_parts()
1418
+ case WaitReason.PR_OPEN.value:
1419
+ return self._pull_request_parts(task.id)
1420
+ case _:
1421
+ return _BlockerParts(f"{_UNRECORDED_REASON}{GROUP_SEPARATOR}{wait_reason}")
1422
+
1423
+ def _dependency_parts(self, relationships: TaskRelationshipsView | None) -> _BlockerParts:
1424
+ if relationships is None:
1425
+ return _BlockerParts(_DEPENDENCY_HEADLINE, (_DEPENDENCY_UNAVAILABLE,))
1426
+ related = relationships.blocking_dependencies
1427
+ count = relationships.blocking_dependency_count
1428
+ if not related:
1429
+ return _BlockerParts(_DEPENDENCY_HEADLINE, (_dependency_absence(relationships),))
1430
+ headline = GROUP_SEPARATOR.join(
1431
+ (_DEPENDENCY_HEADLINE, f"{related[0].task_id} {related[0].status}")
1432
+ )
1433
+ if count > 1:
1434
+ headline = GROUP_SEPARATOR.join((headline, f"{count} unfinished"))
1435
+ shown_from = relationships.page_offset + 1
1436
+ shown_to = relationships.page_offset + len(related)
1437
+ details = (
1438
+ (f"Showing {shown_from} to {shown_to} of {count} unfinished dependencies.",)
1439
+ if count > shown_to
1440
+ else ()
1441
+ )
1442
+ return _BlockerParts(headline, details)
1443
+
1444
+ def _queued_quota_parts(
1445
+ self,
1446
+ task: Task,
1447
+ quota_block: AgentBlock | None,
1448
+ now: datetime,
1449
+ ) -> _BlockerParts:
1450
+ settings = self._settings.current()
1451
+ agents = task_agents(task, settings)
1452
+ blocked = {block.agent: block.until for block in self._active_blocks(now)}
1453
+ quotas = tuple(
1454
+ self._quota_evidence(agent, blocked[agent], now)
1455
+ for agent in quota_blocked_agents(agents, blocked)
1456
+ )
1457
+ raw = tuple(
1458
+ line
1459
+ for agent in dict.fromkeys((agents.leader, agents.implementer))
1460
+ for line in (
1461
+ _account_raw(agent, self._state.agent_block(agent), now),
1462
+ *self._provider_quota_raw(agent),
1463
+ )
1464
+ )
1465
+ if quota_block is None:
1466
+ return _BlockerParts(_QUOTA_HEADLINE, quotas=quotas, raw=raw)
1467
+ wait_limit = now + timedelta(seconds=settings.orchestration.timeouts.max_quota_wait)
1468
+ return _quota_parts(
1469
+ quota_block.until,
1470
+ quotas=quotas,
1471
+ raw=raw,
1472
+ budget_ends=wait_limit,
1473
+ moves=quota_wait_reroutes(task, settings, blocked, wait_limit),
1474
+ exhausted_text=_QUOTA_WAITS_NO_OTHER_AGENT,
1475
+ )
1476
+
1477
+ def _running_quota_parts(
1478
+ self, task: Task, running_wait: _RunningQuotaWait, now: datetime
1479
+ ) -> _BlockerParts:
1480
+ settings = self._settings.current()
1481
+ until = running_wait.block.until
1482
+ blocked_agents = frozenset(block.agent for block in self._active_blocks(now))
1483
+ return _quota_parts(
1484
+ until,
1485
+ quotas=(running_wait.quota,),
1486
+ raw=running_wait.raw,
1487
+ budget_ends=running_wait.budget_ends,
1488
+ moves=running_quota_wait_moves(
1489
+ task, settings, until, running_wait.budget_ends, blocked_agents
1490
+ ),
1491
+ exhausted_text=_QUOTA_ESCALATES_AT_LIMIT,
1492
+ )
1493
+
1494
+ def _running_quota_wait(self, task: Task, now: datetime) -> _RunningQuotaWait | None:
1495
+ if task.status not in _ACTIVE_TURN_STATUSES or task.step is None:
1496
+ return None
1497
+ role = task_acting_role(task)
1498
+ settings = self._settings.current()
1499
+ agent = pick_rung(task, role, settings).agent
1500
+ quota_events = current_quota_wait_events(task, role, agent, self._state.latest_events)
1501
+ if quota_events is None:
1502
+ return None
1503
+ failure, quota_block = quota_events
1504
+ block = self._state.agent_block(agent)
1505
+ if block is None or block.until <= now:
1506
+ return None
1507
+ reset_at = failure.data.get(_RESET_AT_FIELD)
1508
+ reset_text = (
1509
+ reset_at
1510
+ if isinstance(reset_at, str)
1511
+ else QUOTA_NOT_REPORTED
1512
+ if reset_at is None and _RESET_AT_FIELD in failure.data
1513
+ else _UNAVAILABLE
1514
+ )
1515
+ raw = (
1516
+ f"Current turn quota wait · {role.value} · {agent}",
1517
+ f"{_RECORDED_QUOTA_RESET} · {reset_text}",
1518
+ f"Task quota-block event · {quota_block.ts.isoformat()}",
1519
+ _account_raw(agent, block, now),
1520
+ *self._provider_quota_raw(agent),
1521
+ )
1522
+ budget = timedelta(seconds=settings.orchestration.timeouts.max_quota_wait)
1523
+ return _RunningQuotaWait(
1524
+ block=block,
1525
+ budget_ends=quota_block.ts + budget,
1526
+ quota=self._quota_evidence(agent, block.until, now),
1527
+ raw=raw,
1528
+ )
1529
+
1530
+ def _quota_evidence(self, agent: str, until: datetime, now: datetime) -> QuotaEvidenceView:
1531
+ windows = self._state.list_quotas((agent,)).get(agent, [])
1532
+ return QuotaEvidenceView(
1533
+ agent=agent,
1534
+ readings=tuple(
1535
+ QuotaReadingView(
1536
+ window=window.name,
1537
+ used_pct=window.used_pct,
1538
+ resets_at=window.resets_at,
1539
+ observed_at=window.observed_at,
1540
+ )
1541
+ for window in windows
1542
+ if window.resets_at is None or window.resets_at > now
1543
+ ),
1544
+ retry_note=engine_retry_note(until, windows, now),
1545
+ has_recorded_readings=bool(windows),
1546
+ )
1547
+
1548
+ def _provider_quota_raw(self, agent: str) -> tuple[str, ...]:
1549
+ windows = self._state.list_quotas((agent,)).get(agent, [])
1550
+ if not windows:
1551
+ return (f"Provider quota observation · {agent} · {_UNAVAILABLE}",)
1552
+ return tuple(
1553
+ f"Provider quota · {agent} {window.name} · reset {_raw_reset(window.resets_at)} · "
1554
+ f"observed {window.observed_at.isoformat()}"
1555
+ for window in windows
1556
+ )
1557
+
1558
+ def _slot_parts(self) -> _BlockerParts:
1559
+ occupied = [
1560
+ task
1561
+ for task in self._cached_tasks(statuses=(Status.RUNNING, Status.INTEGRATING))
1562
+ if task.slot is not None
1563
+ ]
1564
+ maximum = self._settings.current().orchestration.max_parallel
1565
+ headline = GROUP_SEPARATOR.join((_SLOT_HEADLINE, f"{len(occupied)}/{maximum} busy"))
1566
+ return _BlockerParts(
1567
+ headline,
1568
+ (_SLOT_DETAIL,),
1569
+ related=tuple(_task_reference_view(item) for item in occupied),
1570
+ )
1571
+
1572
+ def _resource_parts(self) -> _BlockerParts:
1573
+ sample = self._sources.resource_sample()
1574
+ if sample is None or sample.sampled_at is None:
1575
+ sample_line = "Dashboard resource sample · unavailable"
1576
+ observed: tuple[datetime, ...] = ()
1577
+ else:
1578
+ sample_line = (
1579
+ "Dashboard resource sample · "
1580
+ f"host CPU {sample.host_cpu_pct:.1f}% · "
1581
+ f"free RAM {sample.free_ram_gb:.1f} GiB"
1582
+ )
1583
+ observed = (sample.sampled_at,)
1584
+ return _BlockerParts(
1585
+ _RESOURCES_HEADLINE,
1586
+ (_RESOURCES_DETAIL,),
1587
+ notes=(sample_line, _RESOURCE_SAMPLE_DISCLAIMER),
1588
+ observed=observed,
1589
+ )
1590
+
1591
+ def _new_task_titles(self, scope: Sequence[Event]) -> dict[str, str]:
1592
+ found = {
1593
+ task_id: self._state.get_task(task_id)
1594
+ for task_id in new_task_ids([event.data for event in scope])
1595
+ }
1596
+ return {task_id: task.title for task_id, task in found.items() if task is not None}
1597
+
1598
+ def _task_results(
1599
+ self,
1600
+ task: Task,
1601
+ detail: TaskDetail,
1602
+ reported: ReportedTests | None,
1603
+ pull_request: PullRequestLine | None,
1604
+ *,
1605
+ verdict_risks: tuple[str, ...],
1606
+ live: TaskWorkSample | None,
1607
+ ) -> TaskResults:
1608
+ done = self._state.latest_events(1, kinds=(EventKind.TASK_DONE,), task_id=task.id)
1609
+ scope = self._state.latest_events(
1610
+ RESULTS_PLAN_CHANGE_LIMIT, kinds=(EventKind.SCOPE_CHANGE,), task_id=task.id
1611
+ )
1612
+ return derive_task_results(
1613
+ task.status,
1614
+ branch=task.repo.branch if task.repo is not None else None,
1615
+ base=task.repo.base if task.repo is not None else None,
1616
+ done=done[0].data if done else None,
1617
+ report_commits=_latest_report_commits(detail.reports),
1618
+ facts=detail.recorded_facts,
1619
+ tests=reported,
1620
+ pull_request=pull_request,
1621
+ scope_events=[event.data for event in scope],
1622
+ titles=self._new_task_titles(scope),
1623
+ verdict_risks=verdict_risks,
1624
+ plan_summary=_latest_plan_summary(detail.reports),
1625
+ commit_cap=self._settings.current().dashboard.results_commits,
1626
+ live=live.work if live is not None else None,
1627
+ live_error=live.error if live is not None else None,
1628
+ )
1629
+
1630
+ def _task_workflow(
1631
+ self,
1632
+ task: Task,
1633
+ window: Sequence[Event],
1634
+ reports: Sequence[ReportRow],
1635
+ ) -> WorkflowProgress:
1636
+ recorded = self._state.latest_events(
1637
+ WORKFLOW_EVENT_LIMIT, kinds=WORKFLOW_EVENTS, task_id=task.id
1638
+ )
1639
+ events = sorted(
1640
+ {event.id: event for event in (*recorded, *window)}.values(), key=attrgetter("id")
1641
+ )
1642
+ agent_reports = tuple(
1643
+ WorkflowReport(turn=report.turn, kind=report.kind, data=report.data)
1644
+ for report in reports
1645
+ if report.source == AGENT_REPORT_SOURCE
1646
+ )
1647
+ return project_workflow(
1648
+ task,
1649
+ events,
1650
+ agent_reports,
1651
+ event_window_complete=len(recorded) < WORKFLOW_EVENT_LIMIT,
1652
+ )
1653
+
1654
+ def _task_decisions(self, task_id: str) -> TaskDecisions:
1655
+ return derive_decisions(
1656
+ self._state.latest_events(DECISION_EVENT_LIMIT, kinds=DECISION_EVENTS, task_id=task_id)
1657
+ )
1658
+
1659
+ def _pull_request_line(self, task_id: str, *, task_done: bool) -> PullRequestLine | None:
1660
+ events = self._state.latest_events(
1661
+ 1,
1662
+ kinds=(EventKind.PR_OPENED, EventKind.PR_WITHDRAWN),
1663
+ task_id=task_id,
1664
+ )
1665
+ return pull_request_line(events[0].data, task_done=task_done) if events else None
1666
+
1667
+ def _pull_request_parts(self, task_id: str) -> _BlockerParts:
1668
+ events = self._state.latest_events(
1669
+ 1,
1670
+ kinds=(EventKind.PR_OPENED, EventKind.PR_WITHDRAWN),
1671
+ task_id=task_id,
1672
+ )
1673
+ if not events:
1674
+ return _BlockerParts(_PR_HEADLINE, (_PR_NO_EVENT, _PR_CI_NOTE))
1675
+ event = events[0]
1676
+ evidence: list[str] = []
1677
+ url = event.data.get("url")
1678
+ state = event.data.get("state")
1679
+ if isinstance(url, str):
1680
+ evidence.append(f"Recorded PR URL · {url}")
1681
+ if isinstance(state, str):
1682
+ evidence.append(f"Latest recorded PR state · {state} · {event.ts.isoformat()}")
1683
+ details = (f"Pull request state · {state}", _PR_CI_NOTE)
1684
+ else:
1685
+ evidence.append(
1686
+ f"Latest recorded PR event · {event.kind.value} · {event.ts.isoformat()}"
1687
+ )
1688
+ details = (_PR_CI_NOTE,)
1689
+ return _BlockerParts(_PR_HEADLINE, details, raw=tuple(evidence))
1690
+
1691
+ def dialogue(self, task_id: str | None = None) -> list[DialogueBubbleView]:
1692
+ """Build recent project or task decisions and activity in event order."""
1693
+ return self._dialogue_view(task_id)
1694
+
1695
+ def usage(self, task_id: str | None = None) -> list[AgentUsageView]:
1696
+ """Build project usage or usage recorded for one task."""
1697
+ now = self._now()
1698
+ self._refresh_usage_cache_version()
1699
+ if task_id is not None:
1700
+ usage = self._task_usage_cache.get(task_id)
1701
+ if usage is None:
1702
+ usage = self._queries.task_usage(task_id)
1703
+ self._task_usage_cache[task_id] = usage
1704
+ return [_agent_usage(item, quotas=()) for item in usage]
1705
+ project_usage = self._project_usage_cache
1706
+ if project_usage is None:
1707
+ project_usage = self._queries.usage_and_quotas()
1708
+ self._project_usage_cache = project_usage
1709
+ usage, quotas = project_usage
1710
+ by_agent = {item.agent: item for item in usage}
1711
+ agents = sorted(set(by_agent) | set(quotas))
1712
+ quota_states = tuple(
1713
+ QuotaMotionState(agent, window.name, window.resets_at)
1714
+ for agent in agents
1715
+ for window in quotas.get(agent, [])
1716
+ )
1717
+ quota_frames = {
1718
+ (item.agent, item.window): item.remaining_seconds
1719
+ for item in self._motion.frames((), now, quotas=quota_states).quotas
1720
+ }
1721
+ result: list[AgentUsageView] = []
1722
+ for agent in agents:
1723
+ item = by_agent.get(agent)
1724
+ windows = tuple(
1725
+ QuotaGaugeView(
1726
+ name=window.name,
1727
+ used_pct=window.used_pct,
1728
+ resets_in=(
1729
+ _quota_countdown(
1730
+ quota_frames[(agent, window.name)], window.resets_at is not None
1731
+ )
1732
+ ),
1733
+ observed_ago=_age(now - window.observed_at),
1734
+ )
1735
+ for window in quotas.get(agent, [])
1736
+ )
1737
+ result.append(
1738
+ AgentUsageView(
1739
+ agent=agent,
1740
+ input_tokens=item.input_tokens if item is not None else 0,
1741
+ output_tokens=item.output_tokens if item is not None else 0,
1742
+ cache_read_tokens=item.cache_read_tokens if item is not None else 0,
1743
+ cache_write_tokens=item.cache_write_tokens if item is not None else 0,
1744
+ cost_usd=item.cost_usd if item is not None else None,
1745
+ cost_basis=item.cost_basis if item is not None else COST_BASIS_UNKNOWN,
1746
+ quotas=windows,
1747
+ run_count=item.run_count if item is not None else 0,
1748
+ usage_reported_runs=item.usage_reported_runs if item is not None else 0,
1749
+ legacy_token_runs=item.legacy_token_runs if item is not None else 0,
1750
+ billed_cost_usd=item.billed_cost_usd if item is not None else None,
1751
+ billed_cost_runs=item.billed_cost_runs if item is not None else 0,
1752
+ api_equivalent_cost_usd=(
1753
+ item.api_equivalent_cost_usd if item is not None else None
1754
+ ),
1755
+ api_equivalent_cost_runs=(
1756
+ item.api_equivalent_cost_runs if item is not None else 0
1757
+ ),
1758
+ unknown_cost_runs=item.unknown_cost_runs if item is not None else 0,
1759
+ )
1760
+ )
1761
+ return result
1762
+
1763
+ def quota_blocks(self) -> list[QuotaBlockView]:
1764
+ """Build one banner per agent whose quota block is still in effect."""
1765
+ now = self._now()
1766
+ waiting = self._quota_waits(now).waiting_by_agent
1767
+ return [
1768
+ QuotaBlockView(
1769
+ agent=block.agent,
1770
+ banner=quota_banner(
1771
+ quota_wait_description(block.agent, to_local(block.until), to_local(now)),
1772
+ waiting[block.agent],
1773
+ ),
1774
+ )
1775
+ for block in self._active_blocks(now)
1776
+ ]
1777
+
1778
+ def command_history(
1779
+ self,
1780
+ *,
1781
+ expanded: bool = False,
1782
+ limit: int | None = None,
1783
+ offset: int = 0,
1784
+ status: CommandFeedbackStatus | None = None,
1785
+ ) -> CommandHistoryView:
1786
+ """Project a bounded durable command-history page and engine liveness."""
1787
+ history = self._queries.command_history(
1788
+ expanded=expanded,
1789
+ limit=limit,
1790
+ offset=offset,
1791
+ status=_stored_command_status(status),
1792
+ )
1793
+ return CommandHistoryView(
1794
+ engine_up=history.engine_up,
1795
+ entries=tuple(
1796
+ CommandFeedbackEntryView(
1797
+ command_id=entry.id,
1798
+ action=entry.action,
1799
+ target=entry.target,
1800
+ status=_command_feedback_status(entry.status),
1801
+ result=(
1802
+ json.dumps(entry.result, ensure_ascii=False, sort_keys=True)
1803
+ if entry.result is not None
1804
+ else None
1805
+ ),
1806
+ queued_at=(entry.timestamp if entry.status is CommandStatus.PENDING else None),
1807
+ )
1808
+ for entry in history.entries
1809
+ ),
1810
+ offset=history.offset,
1811
+ limit=history.limit,
1812
+ total_count=history.total_count,
1813
+ has_more=history.has_more,
1814
+ status=status,
1815
+ )
1816
+
1817
+ async def resources(self) -> ResourceView:
1818
+ """Return the current host and engine/task-pane process-tree sample."""
1819
+ view = await self._sources.resources()
1820
+ for listener in tuple(self._resource_listeners):
1821
+ listener()
1822
+ return view
1823
+
1824
+ def on_resources_refreshed(self, callback: Callable[[], None]) -> Callable[[], None]:
1825
+ """Call `callback` after every `resources()` call; return the unsubscribe function."""
1826
+ self._resource_listeners.append(callback)
1827
+ return partial(self._resource_listeners.remove, callback)
1828
+
1829
+ def resource_sample(self) -> ResourceView | None:
1830
+ """Return the latest resource observation without triggering a new sample."""
1831
+ return self._sources.resource_sample()
1832
+
1833
+ async def containers(self) -> DockerView:
1834
+ """Return the current Docker sample."""
1835
+ return await self._sources.containers()
1836
+
1837
+ async def disk(self) -> DiskView:
1838
+ """Return the disk sample with its free-space level under the configured thresholds."""
1839
+ view = await self._sources.disk()
1840
+ if view.free_bytes is None or view.total_bytes is None:
1841
+ return view
1842
+ level = free_space_level(
1843
+ view.free_bytes,
1844
+ view.total_bytes,
1845
+ warn_pct=self._settings.current().dashboard.disk_warn_free_pct,
1846
+ alert_pct=self._settings.current().dashboard.disk_alert_free_pct,
1847
+ )
1848
+ return replace(view, free_level=level)
1849
+
1850
+ def _strip_with_motion(
1851
+ self, task: Task, motion: TaskMotionFrame, cost_usd: float | None
1852
+ ) -> TaskStripData:
1853
+ data = task_strip_data(task, self._settings.current())
1854
+ panels = list(data.panels)
1855
+ active_index = next(
1856
+ (index for index, panel in enumerate(panels) if panel.state is PanelState.ACTIVE),
1857
+ None,
1858
+ )
1859
+ if active_index is not None:
1860
+ panel = panels[active_index]
1861
+ detail = [panel.detail, _duration(motion.elapsed_seconds)]
1862
+ if cost_usd is not None:
1863
+ detail.append(f"${cost_usd:.2f}")
1864
+ panels[active_index] = replace(
1865
+ panel,
1866
+ state=PanelState.STALLED if motion.stalled else panel.state,
1867
+ detail=" · ".join(part for part in detail if part),
1868
+ spinner_frame=(
1869
+ ""
1870
+ if not motion.spinner_visible
1871
+ else SPINNER_FRAMES[motion.spinner_phase % len(SPINNER_FRAMES)]
1872
+ ),
1873
+ )
1874
+ stamp = data.stamp
1875
+ if motion.stamp_kind is MotionStampKind.VERDICT:
1876
+ stamp = StripStamp(StampKind.VERDICT, motion.stamp_detail, motion.stamp_flash)
1877
+ elif motion.stamp_kind is MotionStampKind.MERGED:
1878
+ stamp = StripStamp(StampKind.MERGED, flash=motion.stamp_flash)
1879
+ return replace(
1880
+ data,
1881
+ panels=tuple(panels),
1882
+ stamp=stamp,
1883
+ actual_step_started_at=motion.actual_step_started_at,
1884
+ last_signal_at=motion.last_signal_at,
1885
+ last_signal=motion.last_signal,
1886
+ last_words=motion.last_words,
1887
+ )
1888
+
1889
+ def _refresh_usage_cache_version(self) -> None:
1890
+ version = self._state.data_version()
1891
+ if version != self._usage_version:
1892
+ self._usage_version = version
1893
+ self._project_usage_cache = None
1894
+ self._project_usage_view_cache = None
1895
+ self._task_usage_cache.clear()
1896
+
1897
+
1898
+ def _publication_state(repo: RepoStatus) -> PublicationState:
1899
+ if repo.unpushed is None or repo.unpulled is None:
1900
+ return PublicationState.UNAVAILABLE
1901
+ if repo.unpulled:
1902
+ return PublicationState.NOT_IN_SYNC
1903
+ if repo.unpushed:
1904
+ return PublicationState.AWAITING_PUSH
1905
+ return PublicationState.IN_SYNC
1906
+
1907
+
1908
+ def _event_role(kind: EventKind, data: dict[str, object]) -> Role | None:
1909
+ if kind is EventKind.VERDICT:
1910
+ return Role.LEADER
1911
+ if kind is EventKind.HELPER_ACTION:
1912
+ return Role.HELPER
1913
+ if kind is EventKind.TURN_COMPLETED:
1914
+ return Role(str(data["role"]))
1915
+ return None
1916
+
1917
+
1918
+ def _event_text(kind: EventKind, message: str, data: dict[str, object]) -> str:
1919
+ if kind is not EventKind.TURN_COMPLETED:
1920
+ return message
1921
+ summary = str(data["summary"])
1922
+ question = data.get("question")
1923
+ return f"{summary}\n{question}" if isinstance(question, str) and question else summary
1924
+
1925
+
1926
+ def _task_reference_view(task: Task) -> TaskReferenceView:
1927
+ return TaskReferenceView(
1928
+ task_id=task.id,
1929
+ title=task.title,
1930
+ status=task.status.value,
1931
+ status_reason=task.status_reason.value if task.status_reason is not None else None,
1932
+ mode=task.mode.value if task.mode is not None else None,
1933
+ )
1934
+
1935
+
1936
+ def _intervention_parts(ownership: tuple[str, ...]) -> _BlockerParts:
1937
+ owners = ", ".join(ownership)
1938
+ takeover = (f"{_RECORDED_TAKEOVER}{GROUP_SEPARATOR}{owners}",) if ownership else ()
1939
+ return _BlockerParts(
1940
+ _INTERVENTION_HEADLINE,
1941
+ (
1942
+ f"{_OWNERSHIP_HOLD_LABEL}{GROUP_SEPARATOR}{owners or _NO_OWNER_RECORDED}",
1943
+ _HAND_BACK_DETAIL,
1944
+ ),
1945
+ raw=(_INTERVENTION_HOLD_EVIDENCE, *takeover),
1946
+ )
1947
+
1948
+
1949
+ def _quota_parts(
1950
+ until: datetime,
1951
+ *,
1952
+ quotas: tuple[QuotaEvidenceView, ...],
1953
+ raw: tuple[str, ...],
1954
+ budget_ends: datetime,
1955
+ moves: bool,
1956
+ exhausted_text: str,
1957
+ ) -> _BlockerParts:
1958
+ return _BlockerParts(
1959
+ headline=_QUOTA_HEADLINE,
1960
+ details=(
1961
+ _quota_reroute_text(
1962
+ moves=moves,
1963
+ exceeds=until > budget_ends,
1964
+ exhausted_text=exhausted_text,
1965
+ ),
1966
+ ),
1967
+ quotas=quotas,
1968
+ raw=raw,
1969
+ )
1970
+
1971
+
1972
+ def _account_raw(agent: str, block: AgentBlock | None, now: datetime) -> str:
1973
+ if block is None:
1974
+ return f"Scheduler quota account block · {agent} · not recorded"
1975
+ activity = "active" if block.until > now else "expired"
1976
+ return (
1977
+ f"Scheduler quota account block · {agent} · {activity} · {block.reason} · "
1978
+ f"retry/probe deadline {block.until.isoformat()}"
1979
+ )
1980
+
1981
+
1982
+ def _raw_reset(resets_at: datetime | None) -> str:
1983
+ return resets_at.isoformat() if resets_at is not None else QUOTA_NOT_REPORTED
1984
+
1985
+
1986
+ def _quota_reroute_text(*, moves: bool, exceeds: bool, exhausted_text: str) -> str:
1987
+ if moves:
1988
+ return _QUOTA_REROUTED
1989
+ return exhausted_text if exceeds else _QUOTA_WAITS_WITHIN_LIMIT
1990
+
1991
+
1992
+ def _dependency_absence(relationships: TaskRelationshipsView) -> str:
1993
+ count = relationships.blocking_dependency_count
1994
+ if count == 0:
1995
+ return _NO_UNFINISHED_DEPENDENCY
1996
+ if relationships.page_offset >= count:
1997
+ return (
1998
+ f"Unfinished dependency evidence is on an earlier relationship page ({count} recorded)."
1999
+ )
2000
+ return _DEPENDENCIES_BEYOND_PAGE
2001
+
2002
+
2003
+ def _task_relationships_view(relationships: TaskRelationships) -> TaskRelationshipsView:
2004
+ return TaskRelationshipsView(
2005
+ family_parent=_task_reference_view(relationships.family_parent),
2006
+ family_root=_task_reference_view(relationships.family_root),
2007
+ family_children=tuple(_task_reference_view(task) for task in relationships.family_children),
2008
+ family_child_count=relationships.family_child_count,
2009
+ dependencies=tuple(_task_reference_view(task) for task in relationships.dependencies),
2010
+ dependency_count=relationships.dependency_count,
2011
+ blocking_dependencies=tuple(
2012
+ _task_reference_view(task) for task in relationships.blocking_dependencies
2013
+ ),
2014
+ blocking_dependency_count=relationships.blocking_dependency_count,
2015
+ dependents=tuple(_task_reference_view(task) for task in relationships.dependents),
2016
+ dependent_count=relationships.dependent_count,
2017
+ replacements=tuple(_task_reference_view(task) for task in relationships.replacements),
2018
+ replacement_count=relationships.replacement_count,
2019
+ family_outcomes=tuple(
2020
+ TaskFamilyOutcomeView(outcome=outcome, count=count)
2021
+ for outcome, count in relationships.family_outcomes
2022
+ ),
2023
+ page_offset=relationships.page_offset,
2024
+ page_size=relationships.page_size,
2025
+ has_more=relationships.has_more,
2026
+ )
2027
+
2028
+
2029
+ def _latest_signal_observation(page: TaskTimelinePageView) -> TaskTimelineEntryView | None:
2030
+ agent_entries = [
2031
+ entry for entry in page.entries if entry.source == TaskTimelineSource.AGENT.value
2032
+ ]
2033
+ signal_entries = [entry for entry in agent_entries if entry.identity.startswith("signal:")]
2034
+ errors = [
2035
+ entry
2036
+ for entry in agent_entries
2037
+ if entry.identity.startswith(("unavailable:", TASK_TIMELINE_READ_ERROR_ID))
2038
+ ]
2039
+ timed = [
2040
+ (timestamp, entry)
2041
+ for entry in signal_entries
2042
+ if (timestamp := _timeline_timestamp(entry.timestamp)) is not None
2043
+ ]
2044
+ if len(timed) < len(signal_entries):
2045
+ return next(
2046
+ entry for entry in signal_entries if _timeline_timestamp(entry.timestamp) is None
2047
+ )
2048
+ if timed:
2049
+ return max(timed, key=lambda item: item[0])[1]
2050
+ return errors[0] if errors else None
2051
+
2052
+
2053
+ def _timeline_timestamp(value: str | None) -> datetime | None:
2054
+ if value is None:
2055
+ return None
2056
+ try:
2057
+ timestamp = datetime.fromisoformat(value)
2058
+ except ValueError:
2059
+ return None
2060
+ return timestamp if timestamp.tzinfo is not None else None
2061
+
2062
+
2063
+ def _task_milestone_views(workflow: WorkflowProgress) -> tuple[TaskMilestoneView, ...]:
2064
+ return tuple(
2065
+ TaskMilestoneView(stage=_stage_label(item.stage), state=item.state.value)
2066
+ for item in workflow.milestones
2067
+ )
2068
+
2069
+
2070
+ def _stage_label(step: Step) -> str:
2071
+ return "Merge" if step is Step.INTEGRATE else step.value.title()
2072
+
2073
+
2074
+ def _duration(seconds: int) -> str:
2075
+ minutes, remainder = divmod(seconds, SECONDS_PER_MINUTE)
2076
+ hours, minutes = divmod(minutes, MINUTES_PER_HOUR)
2077
+ if hours:
2078
+ return f"{hours}h {minutes}m"
2079
+ if minutes:
2080
+ return f"{minutes}m {remainder}s"
2081
+ return f"{remainder}s"
2082
+
2083
+
2084
+ def _quota_countdown(seconds: int | None, has_reset: bool) -> str:
2085
+ if not has_reset:
2086
+ return TIME_UNKNOWN
2087
+ if seconds is None:
2088
+ message = "a quota reset deadline did not produce a countdown"
2089
+ raise RuntimeError(message)
2090
+ if seconds >= SECONDS_PER_DAY:
2091
+ return format_duration(seconds)
2092
+ return _duration(seconds)
2093
+
2094
+
2095
+ def _step_label(status: Status, step: Step | None, iteration: int) -> str:
2096
+ if step is None:
2097
+ return SETUP_STEP if status is Status.RUNNING else UNSTARTED_STEP
2098
+ if step is Step.CONSULT:
2099
+ return CONSULT_STEP
2100
+ if step is Step.INTEGRATE:
2101
+ return INTEGRATION_STEP
2102
+ if step is Step.IMPLEMENT and iteration > 1:
2103
+ return REWORK_STEP
2104
+ return step.value
2105
+
2106
+
2107
+ def _latest_report_commits(reports: Sequence[ReportRow]) -> list[Commit]:
2108
+ empty: list[Commit] = []
2109
+ return next(
2110
+ (
2111
+ item.data.commits
2112
+ for item in reversed(reports)
2113
+ if item.source == AGENT_REPORT_SOURCE
2114
+ and isinstance(item.data, ImplementationReport | PlanReport)
2115
+ ),
2116
+ empty,
2117
+ )
2118
+
2119
+
2120
+ def _latest_plan_summary(reports: Sequence[ReportRow]) -> str | None:
2121
+ return next(
2122
+ (
2123
+ item.data.summary
2124
+ for item in reversed(reports)
2125
+ if item.source == AGENT_REPORT_SOURCE and isinstance(item.data, PlanReport)
2126
+ ),
2127
+ None,
2128
+ )
2129
+
2130
+
2131
+ def _acceptance_criteria(triage: TriageReport | None, request: str) -> tuple[str, ...]:
2132
+ if triage is not None and triage.acceptance_criteria:
2133
+ return tuple(triage.acceptance_criteria)
2134
+ return acceptance_criteria_from_request(request)
2135
+
2136
+
2137
+ def _current_verdict_risks(task: Task, reports: Sequence[ReportRow]) -> tuple[str, ...]:
2138
+ for row in reversed(reports):
2139
+ if isinstance(row.data, Verdict):
2140
+ current = turn_iteration(row.turn) == task.iteration
2141
+ return tuple(row.data.residual_risks) if current else ()
2142
+ return ()
2143
+
2144
+
2145
+ def _task_report(row: ReportRow) -> TaskReportView:
2146
+ report = row.data
2147
+ summary = ""
2148
+ decision = None
2149
+ question = ""
2150
+ issues: tuple[str, ...] = ()
2151
+ risks: tuple[str, ...] = ()
2152
+ tests: tuple[TaskTestView, ...] = ()
2153
+ if isinstance(report, TriageReport):
2154
+ summary = report.title
2155
+ elif isinstance(report, ImplementationReport):
2156
+ summary = report.summary
2157
+ question = report.question
2158
+ risks = tuple(report.risks)
2159
+ tests = tuple(
2160
+ TaskTestView(
2161
+ name=test.name,
2162
+ result=test.result,
2163
+ details=test.details,
2164
+ provenance=AGENT_REPORTED_TESTS,
2165
+ )
2166
+ for test in report.tests
2167
+ )
2168
+ elif isinstance(report, PlanReport):
2169
+ summary = report.summary
2170
+ risks = tuple(report.scope_changes)
2171
+ elif isinstance(report, FeedbackAnalysis):
2172
+ summary = report.summary
2173
+ elif isinstance(report, ConsultAnswer):
2174
+ summary = report.answer or report.decision
2175
+ decision = report.decision
2176
+ else:
2177
+ summary = report.summary
2178
+ decision = report.decision
2179
+ issues = tuple(
2180
+ f"{issue.severity}: {issue.description} {issue.location}".rstrip()
2181
+ for issue in report.issues
2182
+ )
2183
+ risks = tuple(report.residual_risks)
2184
+ return TaskReportView(
2185
+ turn=row.turn,
2186
+ kind=row.kind.value,
2187
+ role=(
2188
+ Role.IMPLEMENTER.value
2189
+ if isinstance(report, ImplementationReport)
2190
+ else Role.LEADER.value
2191
+ ),
2192
+ provenance=row.source,
2193
+ summary=summary,
2194
+ decision=decision,
2195
+ question=question,
2196
+ issues=issues,
2197
+ risks=risks,
2198
+ tests=tests,
2199
+ )
2200
+
2201
+
2202
+ def _recorded_usage(runs: Sequence[Run]) -> RecordedUsageView:
2203
+ usage = aggregate_usage(runs)
2204
+ api_runs = [run for run in runs if run.billing_mode == BILLING_MODE_API]
2205
+ api_usage = aggregate_usage(api_runs)
2206
+ billed_sessions = sum(item.billed_cost_runs for item in usage)
2207
+ api_billed_sessions = sum(item.billed_cost_runs for item in api_usage)
2208
+ api_equivalent_sessions = sum(item.api_equivalent_cost_runs for item in usage)
2209
+ return RecordedUsageView(
2210
+ session_count=len(runs),
2211
+ input_tokens=sum(item.input_tokens for item in usage),
2212
+ output_tokens=sum(item.output_tokens for item in usage),
2213
+ cache_read_tokens=sum(item.cache_read_tokens for item in usage),
2214
+ cache_write_tokens=sum(item.cache_write_tokens for item in usage),
2215
+ reported_sessions=sum(run.usage_reported is True for run in runs),
2216
+ legacy_token_sessions=sum(
2217
+ run.usage_reported is None and has_positive_token_evidence(run) for run in runs
2218
+ ),
2219
+ unknown_token_sessions=sum(
2220
+ run.usage_reported is not True
2221
+ and not (run.usage_reported is None and has_positive_token_evidence(run))
2222
+ for run in runs
2223
+ ),
2224
+ billed_cost_usd=_cost_total((item.billed_cost_usd for item in usage), billed_sessions),
2225
+ billed_cost_sessions=billed_sessions,
2226
+ api_billed_cost_usd=_cost_total(
2227
+ (item.billed_cost_usd for item in api_usage), api_billed_sessions
2228
+ ),
2229
+ api_billed_cost_sessions=api_billed_sessions,
2230
+ api_cost_unreported_sessions=len(api_runs) - api_billed_sessions,
2231
+ api_equivalent_cost_usd=_cost_total(
2232
+ (item.api_equivalent_cost_usd for item in usage), api_equivalent_sessions
2233
+ ),
2234
+ api_equivalent_cost_sessions=api_equivalent_sessions,
2235
+ unknown_billing_sessions=sum(run.billing_mode == BILLING_MODE_UNKNOWN for run in runs),
2236
+ subscription_sessions=sum(run.billing_mode == BILLING_MODE_SUBSCRIPTION for run in runs),
2237
+ subscription_agents=tuple(
2238
+ sorted({run.agent for run in runs if run.billing_mode == BILLING_MODE_SUBSCRIPTION})
2239
+ ),
2240
+ )
2241
+
2242
+
2243
+ def _cost_total(amounts: Iterable[float | None], count: int) -> float | None:
2244
+ if count == 0:
2245
+ return None
2246
+ return sum(amount for amount in amounts if amount is not None)
2247
+
2248
+
2249
+ def _agent_usage(item: UsageView, *, quotas: tuple[QuotaGaugeView, ...]) -> AgentUsageView:
2250
+ return AgentUsageView(
2251
+ agent=item.agent,
2252
+ input_tokens=item.input_tokens,
2253
+ output_tokens=item.output_tokens,
2254
+ cache_read_tokens=item.cache_read_tokens,
2255
+ cache_write_tokens=item.cache_write_tokens,
2256
+ cost_usd=item.cost_usd,
2257
+ cost_basis=item.cost_basis,
2258
+ quotas=quotas,
2259
+ run_count=item.run_count,
2260
+ usage_reported_runs=item.usage_reported_runs,
2261
+ legacy_token_runs=item.legacy_token_runs,
2262
+ billed_cost_usd=item.billed_cost_usd,
2263
+ billed_cost_runs=item.billed_cost_runs,
2264
+ api_equivalent_cost_usd=item.api_equivalent_cost_usd,
2265
+ api_equivalent_cost_runs=item.api_equivalent_cost_runs,
2266
+ unknown_cost_runs=item.unknown_cost_runs,
2267
+ )
2268
+
2269
+
2270
+ def _command_feedback_status(status: CommandStatus) -> CommandFeedbackStatus:
2271
+ if status is CommandStatus.PENDING:
2272
+ return CommandFeedbackStatus.PENDING
2273
+ if status is CommandStatus.DONE:
2274
+ return CommandFeedbackStatus.APPLIED
2275
+ return CommandFeedbackStatus.REJECTED
2276
+
2277
+
2278
+ def _stored_command_status(
2279
+ status: CommandFeedbackStatus | None,
2280
+ ) -> CommandStatus | None:
2281
+ if status is None:
2282
+ return None
2283
+ if status is CommandFeedbackStatus.PENDING:
2284
+ return CommandStatus.PENDING
2285
+ if status is CommandFeedbackStatus.APPLIED:
2286
+ return CommandStatus.DONE
2287
+ return CommandStatus.REJECTED
2288
+
2289
+
2290
+ def _timeline_read_error(
2291
+ error: ShikiruError | OSError | ValueError | TypeError,
2292
+ *,
2293
+ role: str | None,
2294
+ turn: str | None,
2295
+ ) -> TaskTimelinePageView:
2296
+ return TaskTimelinePageView(
2297
+ entries=(
2298
+ TaskTimelineEntryView(
2299
+ identity=TASK_TIMELINE_READ_ERROR_ID,
2300
+ timestamp=None,
2301
+ source=TaskTimelineSource.AGENT.value,
2302
+ category=TIMELINE_EVIDENCE_CATEGORY,
2303
+ summary=TASK_TIMELINE_READ_ERROR_SUMMARY,
2304
+ phrase=TASK_TIMELINE_READ_ERROR_SUMMARY,
2305
+ glyph=GLYPH_ATTENTION,
2306
+ tone=Tone.WARNING.value,
2307
+ detail=str(error),
2308
+ role=role,
2309
+ turn=turn,
2310
+ ),
2311
+ ),
2312
+ older_cursor=None,
2313
+ has_older=False,
2314
+ )