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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- 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
|
+
)
|