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,1601 @@
|
|
|
1
|
+
"""Read-only operator views over the state reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
15
|
+
|
|
16
|
+
from shikiru.agents.contracts import (
|
|
17
|
+
COST_BASIS_UNKNOWN,
|
|
18
|
+
CostBasis,
|
|
19
|
+
QuotaWindow,
|
|
20
|
+
Usage,
|
|
21
|
+
)
|
|
22
|
+
from shikiru.config.settings import MAX_TIMELINE_PAGE_SIZE, MAX_TIMELINE_SIGNAL_SCAN_ROWS
|
|
23
|
+
from shikiru.domain.commands import (
|
|
24
|
+
Command,
|
|
25
|
+
CommandStatus,
|
|
26
|
+
HandBack,
|
|
27
|
+
ResolutionKind,
|
|
28
|
+
Resolve,
|
|
29
|
+
Say,
|
|
30
|
+
SetRung,
|
|
31
|
+
Stop,
|
|
32
|
+
Submit,
|
|
33
|
+
TakeOver,
|
|
34
|
+
)
|
|
35
|
+
from shikiru.domain.engine_uptime import project_engine_uptime
|
|
36
|
+
from shikiru.domain.escalation import EscalationStatus
|
|
37
|
+
from shikiru.domain.escalation_evidence import parse_escalation_packet
|
|
38
|
+
from shikiru.domain.events import Event, EventKind
|
|
39
|
+
from shikiru.domain.faults import FaultStreak, open_fault_streaks
|
|
40
|
+
from shikiru.domain.reports import (
|
|
41
|
+
FACTS_FILENAME,
|
|
42
|
+
HELPER_REPORT_FILENAME,
|
|
43
|
+
REPORT_FILENAME,
|
|
44
|
+
REPORT_KIND_FILENAME,
|
|
45
|
+
FeedbackVerdict,
|
|
46
|
+
Option,
|
|
47
|
+
Report,
|
|
48
|
+
ReportKind,
|
|
49
|
+
)
|
|
50
|
+
from shikiru.domain.runs import Run, RunStatus, has_positive_token_evidence
|
|
51
|
+
from shikiru.domain.scheduling import dispatch_sort_key, occupied_slot_ids
|
|
52
|
+
from shikiru.domain.status_tasks import select_human_status_tasks
|
|
53
|
+
from shikiru.domain.steps import task_acting_role
|
|
54
|
+
from shikiru.domain.tasks import ACTIVE_STATUSES, Role, Status, Task
|
|
55
|
+
from shikiru.domain.timeline import (
|
|
56
|
+
TIMELINE_ACTIVITY_CATEGORY,
|
|
57
|
+
TIMELINE_ALL_CATEGORY,
|
|
58
|
+
TIMELINE_CATEGORIES,
|
|
59
|
+
TIMELINE_DECISION_CATEGORY,
|
|
60
|
+
TIMELINE_DECISION_KINDS,
|
|
61
|
+
TIMELINE_DEFAULT_CATEGORY,
|
|
62
|
+
TIMELINE_ENGINE_CATEGORY,
|
|
63
|
+
TIMELINE_JSON_INDENT,
|
|
64
|
+
TIMELINE_SOURCE_AGENT,
|
|
65
|
+
TIMELINE_SOURCE_ENGINE,
|
|
66
|
+
TIMELINE_TRANSITION_CATEGORY,
|
|
67
|
+
event_category,
|
|
68
|
+
)
|
|
69
|
+
from shikiru.domain.timeline_phrases import event_phrase
|
|
70
|
+
from shikiru.errors import ShikiruError
|
|
71
|
+
from shikiru.operator.timeline import (
|
|
72
|
+
TIMELINE_READ_ERROR_PREFIX,
|
|
73
|
+
SignalBatch,
|
|
74
|
+
TimelineCursor,
|
|
75
|
+
TimelineFilters,
|
|
76
|
+
TimelineItem,
|
|
77
|
+
decode_cursor,
|
|
78
|
+
encode_cursor,
|
|
79
|
+
event_category_is_requested,
|
|
80
|
+
initial_cursor,
|
|
81
|
+
read_signal_batch,
|
|
82
|
+
signal_category_is_requested,
|
|
83
|
+
)
|
|
84
|
+
from shikiru.paths import (
|
|
85
|
+
engine_log_path,
|
|
86
|
+
engine_output_path,
|
|
87
|
+
run_dir,
|
|
88
|
+
runs_root,
|
|
89
|
+
state_db_path,
|
|
90
|
+
task_root,
|
|
91
|
+
turns_dir,
|
|
92
|
+
)
|
|
93
|
+
from shikiru.store.database import format_timestamp
|
|
94
|
+
from shikiru.store.escalations import Escalation
|
|
95
|
+
from shikiru.telemetry.disk import directory_usage
|
|
96
|
+
from shikiru.view_log import ordered_turn_dirs, read_tail_lines, view_log_path
|
|
97
|
+
from shikiru.workspace.reports import (
|
|
98
|
+
ENGINE_VERDICT_FILENAME,
|
|
99
|
+
InvalidTurnReportError,
|
|
100
|
+
load_engine_verdict,
|
|
101
|
+
load_turn_report,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if TYPE_CHECKING:
|
|
105
|
+
from shikiru.clock import Clock
|
|
106
|
+
from shikiru.domain.escalation_evidence import EscalationPacket
|
|
107
|
+
from shikiru.process import ProcessIdentity
|
|
108
|
+
from shikiru.store.commands import StoredCommand
|
|
109
|
+
from shikiru.store.reader import StateReader
|
|
110
|
+
from shikiru.store.tasks import TaskRelationships
|
|
111
|
+
|
|
112
|
+
PEEK_TAIL_LINES = 60
|
|
113
|
+
TASK_DETAIL_EVENT_LIMIT = 40
|
|
114
|
+
ENGINE_UPTIME_EVENT_LIMIT = 2
|
|
115
|
+
ENGINE_LIFECYCLE_EVENTS = (EventKind.ENGINE_STARTED, EventKind.ENGINE_STOPPED)
|
|
116
|
+
ENGINE_REPORT_SOURCE = "engine"
|
|
117
|
+
AGENT_REPORT_SOURCE = "agent"
|
|
118
|
+
TASK_REPORT_ERROR = "could not read report evidence: {error}"
|
|
119
|
+
TASK_FACTS_ERROR = "could not read recorded git facts: {error}"
|
|
120
|
+
TASK_REPORT_TURN_MISSING = "No validated report or verdict is available for turn {turn}."
|
|
121
|
+
TASK_FACTS_TURN_MISSING = "No recorded git facts are available for turn {turn}."
|
|
122
|
+
TASK_ACTION_MISSING = "task no longer exists"
|
|
123
|
+
TASK_ACTION_TERMINAL = "task is terminal"
|
|
124
|
+
_COST_BASIS_BILLED = "billed"
|
|
125
|
+
_COST_BASIS_API_EQUIVALENT = "api_equivalent"
|
|
126
|
+
TASK_ACTION_ROLE_MISSING = "role pane is unavailable"
|
|
127
|
+
TASK_ACTION_TAKEN_OVER = "this role is already taken over"
|
|
128
|
+
TASK_ACTION_SESSION_MISSING = "this role has no live session to take over"
|
|
129
|
+
TASK_ACTION_NOT_TAKEN_OVER = "this role is not taken over"
|
|
130
|
+
TASK_ACTION_OTHER_ROLE_TAKEN_OVER = "hand back all taken-over roles before resuming"
|
|
131
|
+
TASK_ACTION_NO_HOLD = "task has no pause or ownership hold"
|
|
132
|
+
TASK_ACTION_NOT_QUEUED = "task can only be prioritized while queued"
|
|
133
|
+
TASK_ACTION_ROLES = (Role.LEADER, Role.IMPLEMENTER)
|
|
134
|
+
TASK_PANE_CONTEXT_STATUSES = (*ACTIVE_STATUSES, Status.QUEUED)
|
|
135
|
+
TASK_TARGET_SEARCH_CATEGORY = "all"
|
|
136
|
+
RECENT_COMMAND_HISTORY_LIMIT = 3
|
|
137
|
+
PROJECT_COMMAND_HISTORY_LIMIT = 50
|
|
138
|
+
ESCALATION_STATUS_MISSING = "task {task_id} is missing; {escalation_id} is stale."
|
|
139
|
+
ESCALATION_STATUS_STALE = "task {task_id} is {status}; {escalation_id} is stale."
|
|
140
|
+
ESCALATION_STATUS_RESOLVED = "{escalation_id} was resolved; task {task_id} is {status}."
|
|
141
|
+
ESCALATION_STATUS_NOT_OPEN = "escalation {escalation_id} is not open"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class OperatorTaskAction(StrEnum):
|
|
145
|
+
"""A task action whose availability is projected from current operator-visible state."""
|
|
146
|
+
|
|
147
|
+
PRIORITIZE = "prioritize"
|
|
148
|
+
PAUSE = "pause"
|
|
149
|
+
PAUSE_NOW = "pause-now"
|
|
150
|
+
RESUME = "resume"
|
|
151
|
+
TAKE_OVER = "take-over"
|
|
152
|
+
HAND_BACK = "hand-back"
|
|
153
|
+
CANCEL = "cancel"
|
|
154
|
+
RETRY = "retry"
|
|
155
|
+
RUNG = "rung"
|
|
156
|
+
MESSAGE = "message"
|
|
157
|
+
PEEK = "peek"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class TaskActionAvailability:
|
|
162
|
+
"""The current availability and any disabled reason for one task action."""
|
|
163
|
+
|
|
164
|
+
action: OperatorTaskAction
|
|
165
|
+
disabled_reason: str | None
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def enabled(self) -> bool:
|
|
169
|
+
"""Whether the current task state permits presenting this action as available."""
|
|
170
|
+
return self.disabled_reason is None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class TaskTargetSearchPage:
|
|
175
|
+
"""One bounded page of task targets and any selected target outside its search."""
|
|
176
|
+
|
|
177
|
+
tasks: tuple[TaskSummary, ...]
|
|
178
|
+
selected_task: TaskSummary | None
|
|
179
|
+
matching_count: int
|
|
180
|
+
project_count: int
|
|
181
|
+
has_more: bool
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class OperatorView(BaseModel):
|
|
185
|
+
"""Base model for JSON-safe operator views."""
|
|
186
|
+
|
|
187
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class CommandHistoryEntry(OperatorView):
|
|
191
|
+
"""One durable command with its stored lifecycle state and returned result."""
|
|
192
|
+
|
|
193
|
+
id: int
|
|
194
|
+
action: str
|
|
195
|
+
target: str
|
|
196
|
+
status: CommandStatus
|
|
197
|
+
result: dict[str, object] | None
|
|
198
|
+
timestamp: str
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class CommandHistory(OperatorView):
|
|
202
|
+
"""A bounded newest-first command history and the engine's current liveness."""
|
|
203
|
+
|
|
204
|
+
engine_up: bool
|
|
205
|
+
entries: list[CommandHistoryEntry]
|
|
206
|
+
offset: int
|
|
207
|
+
limit: int
|
|
208
|
+
total_count: int
|
|
209
|
+
has_more: bool
|
|
210
|
+
status: CommandStatus | None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TaskTimelineEntry(OperatorView):
|
|
214
|
+
"""One engine event or normalized agent signal on a task's recorded timeline."""
|
|
215
|
+
|
|
216
|
+
identity: str
|
|
217
|
+
timestamp: str | None
|
|
218
|
+
timestamp_error: str | None
|
|
219
|
+
source: str
|
|
220
|
+
category: str
|
|
221
|
+
summary: str
|
|
222
|
+
phrase: str
|
|
223
|
+
glyph: str
|
|
224
|
+
tone: str
|
|
225
|
+
detail: str
|
|
226
|
+
role: str | None
|
|
227
|
+
turn: str | None
|
|
228
|
+
event_id: int | None
|
|
229
|
+
incomplete: bool
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class TaskTimelinePage(OperatorView):
|
|
233
|
+
"""One bounded newest-first evidence page and its stable older-page cursor."""
|
|
234
|
+
|
|
235
|
+
entries: list[TaskTimelineEntry]
|
|
236
|
+
older_cursor: str | None
|
|
237
|
+
has_older: bool
|
|
238
|
+
filters: dict[str, str | None]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class TaskSummary(OperatorView):
|
|
242
|
+
"""The scheduling fields shown for one task in a queue or status view."""
|
|
243
|
+
|
|
244
|
+
id: str
|
|
245
|
+
title: str
|
|
246
|
+
status: Status
|
|
247
|
+
step: str | None
|
|
248
|
+
priority: int
|
|
249
|
+
wait_reason: str | None
|
|
250
|
+
holds: frozenset[str]
|
|
251
|
+
slot: int | None
|
|
252
|
+
parent_id: str | None
|
|
253
|
+
summary: str | None
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def from_task(cls, task: Task) -> TaskSummary:
|
|
257
|
+
return cls(
|
|
258
|
+
id=task.id,
|
|
259
|
+
title=task.title,
|
|
260
|
+
status=task.status,
|
|
261
|
+
step=task.step.value if task.step is not None else None,
|
|
262
|
+
priority=task.priority,
|
|
263
|
+
wait_reason=task.wait_reason,
|
|
264
|
+
holds=frozenset(hold.value for hold in task.holds),
|
|
265
|
+
slot=task.slot,
|
|
266
|
+
parent_id=task.parent_id,
|
|
267
|
+
summary=task.summary,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class ReportRow(OperatorView):
|
|
272
|
+
"""A turn's validated report in a task detail view."""
|
|
273
|
+
|
|
274
|
+
turn: str
|
|
275
|
+
kind: ReportKind
|
|
276
|
+
data: Report
|
|
277
|
+
source: str = AGENT_REPORT_SOURCE
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True)
|
|
281
|
+
class TaskExportFiles:
|
|
282
|
+
"""All retained validated report and facts files with source read errors."""
|
|
283
|
+
|
|
284
|
+
reports: tuple[ReportRow, ...]
|
|
285
|
+
facts: tuple[tuple[str, str], ...]
|
|
286
|
+
errors: tuple[tuple[str, str], ...]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TaskDetail(OperatorView):
|
|
290
|
+
"""A task with its runs and event timeline."""
|
|
291
|
+
|
|
292
|
+
task: Task
|
|
293
|
+
events: list[Event] = Field(default_factory=list)
|
|
294
|
+
reports: list[ReportRow] = Field(default_factory=list)
|
|
295
|
+
recorded_facts: str | None = None
|
|
296
|
+
facts_turn: str | None = None
|
|
297
|
+
evidence_errors: list[str] = Field(default_factory=list)
|
|
298
|
+
branches: list[str] = Field(default_factory=list)
|
|
299
|
+
sessions: list[Run] = Field(default_factory=list)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
type TaskStaticFileEvidence = tuple[
|
|
303
|
+
list[ReportRow],
|
|
304
|
+
str | None,
|
|
305
|
+
str | None,
|
|
306
|
+
list[str],
|
|
307
|
+
]
|
|
308
|
+
type FileIdentity = tuple[str, int | None, int | None, int | None, str | None]
|
|
309
|
+
type TaskFileSignature = tuple[FileIdentity, ...]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class TaskFileCache:
|
|
314
|
+
task_id: str
|
|
315
|
+
data_version: int
|
|
316
|
+
roles: tuple[str, ...]
|
|
317
|
+
signature: TaskFileSignature
|
|
318
|
+
static_files: TaskStaticFileEvidence
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass(frozen=True)
|
|
322
|
+
class PacketFileCache:
|
|
323
|
+
modified_ns: int
|
|
324
|
+
changed_ns: int
|
|
325
|
+
size: int
|
|
326
|
+
text: str
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class EscalationResolutionView(OperatorView):
|
|
330
|
+
"""One resolution the engine accepts for an escalation, as its packet lists it."""
|
|
331
|
+
|
|
332
|
+
kind: ResolutionKind
|
|
333
|
+
consequences: str
|
|
334
|
+
recommended: bool
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class EscalationView(OperatorView):
|
|
338
|
+
"""An escalation row, its packet text and the decision fields parsed from that packet."""
|
|
339
|
+
|
|
340
|
+
escalation: Escalation
|
|
341
|
+
packet: str | None
|
|
342
|
+
packet_error: str | None = None
|
|
343
|
+
question: str | None = None
|
|
344
|
+
agent_options: list[Option] = Field(default_factory=list)
|
|
345
|
+
resolutions: list[EscalationResolutionView] = Field(default_factory=list)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class PeekPane(OperatorView):
|
|
349
|
+
"""One role's last message, usage and most recent view log lines."""
|
|
350
|
+
|
|
351
|
+
agent: str
|
|
352
|
+
last_message: str | None
|
|
353
|
+
usage: Usage
|
|
354
|
+
view: list[str]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class PeekView(OperatorView):
|
|
358
|
+
"""The recorded state of one or more task roles, keyed by role."""
|
|
359
|
+
|
|
360
|
+
task_id: str
|
|
361
|
+
panes: dict[str, PeekPane]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class SlotView(OperatorView):
|
|
365
|
+
"""The engine's configured and currently occupied task slots."""
|
|
366
|
+
|
|
367
|
+
active: int
|
|
368
|
+
maximum: int
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class UsageView(OperatorView):
|
|
372
|
+
"""Aggregated usage for one agent across recorded runs."""
|
|
373
|
+
|
|
374
|
+
agent: str
|
|
375
|
+
input_tokens: int
|
|
376
|
+
output_tokens: int
|
|
377
|
+
cache_read_tokens: int
|
|
378
|
+
cache_write_tokens: int
|
|
379
|
+
cost_usd: float | None
|
|
380
|
+
cost_basis: CostBasis | Literal["mixed"]
|
|
381
|
+
run_count: int = 0
|
|
382
|
+
usage_reported_runs: int = 0
|
|
383
|
+
legacy_token_runs: int = 0
|
|
384
|
+
billed_cost_usd: float | None = None
|
|
385
|
+
billed_cost_runs: int = 0
|
|
386
|
+
legacy_billed_cost_runs: int = 0
|
|
387
|
+
api_equivalent_cost_usd: float | None = None
|
|
388
|
+
api_equivalent_cost_runs: int = 0
|
|
389
|
+
legacy_api_equivalent_cost_runs: int = 0
|
|
390
|
+
unknown_cost_runs: int = 0
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class RunDirectoryUsage(OperatorView):
|
|
394
|
+
"""How many task run directories are kept and the bytes they take."""
|
|
395
|
+
|
|
396
|
+
directories: int
|
|
397
|
+
size_bytes: int
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class StatusView(OperatorView):
|
|
401
|
+
"""The status screen's engine, queue, task, escalation and usage views."""
|
|
402
|
+
|
|
403
|
+
project: str
|
|
404
|
+
engine_up: bool
|
|
405
|
+
slots: SlotView
|
|
406
|
+
active_tasks: list[TaskSummary]
|
|
407
|
+
queued_tasks: list[TaskSummary]
|
|
408
|
+
escalations: list[EscalationView]
|
|
409
|
+
usage: list[UsageView]
|
|
410
|
+
quotas: dict[str, list[QuotaWindow]]
|
|
411
|
+
faults: list[FaultStreak]
|
|
412
|
+
oldest_escalation_age_seconds: float | None
|
|
413
|
+
run_directories: RunDirectoryUsage
|
|
414
|
+
engine_output: str | None = None
|
|
415
|
+
task: TaskDetail | None = None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@dataclass(frozen=True)
|
|
419
|
+
class HumanStatusView:
|
|
420
|
+
"""A status view with the bounded task rows selected for human project output."""
|
|
421
|
+
|
|
422
|
+
status: StatusView
|
|
423
|
+
task_rows: tuple[TaskSummary, ...]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
StatusView.model_rebuild(_types_namespace={"datetime": datetime})
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
EngineState = Callable[[], bool]
|
|
430
|
+
EngineIdentity = Callable[[], "ProcessIdentity | None"]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class OperatorQueryError(ShikiruError):
|
|
434
|
+
"""A requested operator view could not be read at its boundary."""
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class TaskNotFoundError(OperatorQueryError):
|
|
438
|
+
"""A requested task does not exist in the current project."""
|
|
439
|
+
|
|
440
|
+
def __init__(self, task_id: str, project_root: Path) -> None:
|
|
441
|
+
self.task_id = task_id
|
|
442
|
+
self.project_root = project_root
|
|
443
|
+
super().__init__(f"Task {task_id} was not found in this project.")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class OperatorQueries:
|
|
447
|
+
"""Provide read-only status, task, escalation and pane views to operators."""
|
|
448
|
+
|
|
449
|
+
def __init__(
|
|
450
|
+
self,
|
|
451
|
+
reader: StateReader,
|
|
452
|
+
*,
|
|
453
|
+
project: str,
|
|
454
|
+
project_root: Path,
|
|
455
|
+
engine_up: EngineState,
|
|
456
|
+
engine_identity: EngineIdentity | None = None,
|
|
457
|
+
max_parallel: int,
|
|
458
|
+
clock: Clock,
|
|
459
|
+
) -> None:
|
|
460
|
+
"""Use `reader` and `engine_up` for the operator's read boundaries."""
|
|
461
|
+
self._reader = reader
|
|
462
|
+
self._project = project
|
|
463
|
+
self._project_root = project_root
|
|
464
|
+
self._engine_up = engine_up
|
|
465
|
+
self._engine_identity = engine_identity
|
|
466
|
+
self._max_parallel = max_parallel
|
|
467
|
+
self._clock = clock
|
|
468
|
+
self._task_detail_file_cache: TaskFileCache | None = None
|
|
469
|
+
self._escalation_packet_cache: dict[Path, PacketFileCache] = {}
|
|
470
|
+
|
|
471
|
+
def status(self, task_id: str | None = None) -> StatusView:
|
|
472
|
+
"""Return the global status view, optionally including one task's detail."""
|
|
473
|
+
tasks = self._reader.list_tasks()
|
|
474
|
+
return self._build_status_view(tasks, task_id)
|
|
475
|
+
|
|
476
|
+
def human_status(self, task_id: str | None = None) -> HumanStatusView:
|
|
477
|
+
"""Return status and bounded task rows from one project task snapshot."""
|
|
478
|
+
tasks = self._reader.list_tasks()
|
|
479
|
+
if task_id is not None and not any(task.id == task_id for task in tasks):
|
|
480
|
+
raise TaskNotFoundError(task_id, self._project_root)
|
|
481
|
+
status = self._build_status_view(tasks, task_id, human_task_detail=True)
|
|
482
|
+
task_rows = (
|
|
483
|
+
tuple(TaskSummary.from_task(task) for task in select_human_status_tasks(tasks))
|
|
484
|
+
if status.task is None
|
|
485
|
+
else ()
|
|
486
|
+
)
|
|
487
|
+
return HumanStatusView(status=status, task_rows=task_rows)
|
|
488
|
+
|
|
489
|
+
def _build_status_view(
|
|
490
|
+
self, tasks: list[Task], task_id: str | None, *, human_task_detail: bool = False
|
|
491
|
+
) -> StatusView:
|
|
492
|
+
queued_tasks = sorted(
|
|
493
|
+
(task for task in tasks if task.status is Status.QUEUED), key=dispatch_sort_key
|
|
494
|
+
)
|
|
495
|
+
summaries = [TaskSummary.from_task(task) for task in tasks]
|
|
496
|
+
active = [summary for summary in summaries if summary.status in ACTIVE_STATUSES]
|
|
497
|
+
queued = [TaskSummary.from_task(task) for task in queued_tasks]
|
|
498
|
+
open_escalations = self.list_escalations()
|
|
499
|
+
usage, quotas = self.usage_and_quotas()
|
|
500
|
+
task_detail = (
|
|
501
|
+
self._human_task_detail(task_id)
|
|
502
|
+
if task_id is not None and human_task_detail
|
|
503
|
+
else self.get_task(task_id)
|
|
504
|
+
if task_id is not None
|
|
505
|
+
else None
|
|
506
|
+
)
|
|
507
|
+
return StatusView(
|
|
508
|
+
project=self._project,
|
|
509
|
+
engine_up=self._engine_up(),
|
|
510
|
+
slots=SlotView(active=len(occupied_slot_ids(tasks)), maximum=self._max_parallel),
|
|
511
|
+
active_tasks=active,
|
|
512
|
+
queued_tasks=queued,
|
|
513
|
+
escalations=open_escalations,
|
|
514
|
+
usage=usage,
|
|
515
|
+
quotas=quotas,
|
|
516
|
+
faults=self.open_faults(),
|
|
517
|
+
oldest_escalation_age_seconds=self.oldest_escalation_age_seconds(),
|
|
518
|
+
run_directories=self.run_directory_usage(),
|
|
519
|
+
engine_output=self._engine_output(),
|
|
520
|
+
task=task_detail,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def _human_task_detail(self, task_id: str) -> TaskDetail | None:
|
|
524
|
+
task = self._reader.get_task(task_id)
|
|
525
|
+
if task is None:
|
|
526
|
+
return None
|
|
527
|
+
runs = self._reader.list_runs(task_id)
|
|
528
|
+
turn_dirs, evidence_errors = self._task_turn_dirs(task_id)
|
|
529
|
+
reports: list[ReportRow] = []
|
|
530
|
+
recorded_facts = None
|
|
531
|
+
facts_turn = None
|
|
532
|
+
if turn_dirs is not None:
|
|
533
|
+
for turn_dir in turn_dirs:
|
|
534
|
+
turn_reports, turn_errors = _task_turn_reports(turn_dir)
|
|
535
|
+
reports.extend(turn_reports)
|
|
536
|
+
evidence_errors.extend(turn_errors)
|
|
537
|
+
recorded_facts, facts_turn, fact_errors = _task_facts(turn_dirs)
|
|
538
|
+
evidence_errors.extend(fact_errors)
|
|
539
|
+
return TaskDetail(
|
|
540
|
+
task=task,
|
|
541
|
+
events=self._reader.events_since(0, task_id=task_id),
|
|
542
|
+
reports=reports,
|
|
543
|
+
recorded_facts=recorded_facts,
|
|
544
|
+
facts_turn=facts_turn,
|
|
545
|
+
evidence_errors=evidence_errors,
|
|
546
|
+
branches=[task.repo.branch] if task.repo is not None else [],
|
|
547
|
+
sessions=runs,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def engine_is_up(self) -> bool:
|
|
551
|
+
"""Return whether this project's engine currently holds its lock."""
|
|
552
|
+
return self._engine_up()
|
|
553
|
+
|
|
554
|
+
def current_engine_uptime_seconds(self) -> float | None:
|
|
555
|
+
"""Return current uptime only when the verified lock identity matches start evidence."""
|
|
556
|
+
process_identity = self._engine_identity() if self._engine_identity is not None else None
|
|
557
|
+
identity = (
|
|
558
|
+
(process_identity.pid, process_identity.started_at)
|
|
559
|
+
if process_identity is not None and process_identity.started_at is not None
|
|
560
|
+
else None
|
|
561
|
+
)
|
|
562
|
+
projection = project_engine_uptime(
|
|
563
|
+
self._reader.latest_events(ENGINE_UPTIME_EVENT_LIMIT, kinds=ENGINE_LIFECYCLE_EVENTS),
|
|
564
|
+
now=self._clock.now(),
|
|
565
|
+
running=process_identity is not None,
|
|
566
|
+
identity=identity,
|
|
567
|
+
)
|
|
568
|
+
return projection.running_seconds
|
|
569
|
+
|
|
570
|
+
def _engine_output(self) -> str | None:
|
|
571
|
+
path = engine_output_path(self._project_root)
|
|
572
|
+
if self._engine_up() or not path.is_file() or path.stat().st_size == 0:
|
|
573
|
+
return None
|
|
574
|
+
return str(path)
|
|
575
|
+
|
|
576
|
+
def run_directory_usage(self) -> RunDirectoryUsage:
|
|
577
|
+
"""Return the number and total size of the task run directories under the project."""
|
|
578
|
+
usage = directory_usage(runs_root(self._project_root))
|
|
579
|
+
return RunDirectoryUsage(directories=usage.directories, size_bytes=usage.size_bytes)
|
|
580
|
+
|
|
581
|
+
def open_faults(self) -> list[FaultStreak]:
|
|
582
|
+
"""Return the engine fault streaks recorded and not yet recovered."""
|
|
583
|
+
return open_fault_streaks(
|
|
584
|
+
self._reader.events_since(0, kinds=[EventKind.ENGINE_FAULT, EventKind.ENGINE_RECOVERED])
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def engine_log_location(self) -> str:
|
|
588
|
+
"""Return the path of the engine log, which holds the traceback of each logged fault."""
|
|
589
|
+
return str(engine_log_path(self._project_root))
|
|
590
|
+
|
|
591
|
+
def state_database_location(self) -> str:
|
|
592
|
+
"""Return the path of the state database, which records each engine fault event."""
|
|
593
|
+
return str(state_db_path(self._project_root))
|
|
594
|
+
|
|
595
|
+
def oldest_escalation_age_seconds(self) -> float | None:
|
|
596
|
+
"""Return the age of the oldest unresolved escalation, or `None` when none is open."""
|
|
597
|
+
created = [item.created_at for item in self._reader.list_open_escalations()]
|
|
598
|
+
return (self._clock.now() - min(created)).total_seconds() if created else None
|
|
599
|
+
|
|
600
|
+
def usage_and_quotas(self) -> tuple[list[UsageView], dict[str, list[QuotaWindow]]]:
|
|
601
|
+
"""Return shared usage totals and provider-reported quota windows."""
|
|
602
|
+
runs = self._reader.list_all_runs()
|
|
603
|
+
agents = sorted({run.agent for run in runs} | set(self._reader.list_quota_agents()))
|
|
604
|
+
return aggregate_usage(runs), self._reader.list_quotas(agents)
|
|
605
|
+
|
|
606
|
+
def task_usage(self, task_id: str) -> list[UsageView]:
|
|
607
|
+
"""Return agent-reported usage totals for one task's sessions."""
|
|
608
|
+
return aggregate_usage(self._reader.list_runs(task_id))
|
|
609
|
+
|
|
610
|
+
def task_runs(self, task_id: str) -> tuple[Run, ...]:
|
|
611
|
+
"""Return every persisted session row for one task in recorded order."""
|
|
612
|
+
return tuple(self._reader.list_runs(task_id))
|
|
613
|
+
|
|
614
|
+
def task_relationships(
|
|
615
|
+
self,
|
|
616
|
+
task_id: str,
|
|
617
|
+
*,
|
|
618
|
+
offset: int = 0,
|
|
619
|
+
) -> TaskRelationships | None:
|
|
620
|
+
"""Return one bounded page of stored relationships for the selected task."""
|
|
621
|
+
return self._reader.task_relationships(task_id, offset=offset)
|
|
622
|
+
|
|
623
|
+
async def task_export_files(self, task_id: str) -> TaskExportFiles:
|
|
624
|
+
"""Load all retained validated reports and facts away from the UI event loop."""
|
|
625
|
+
return await asyncio.to_thread(self._task_all_export_files, task_id)
|
|
626
|
+
|
|
627
|
+
def list_tasks(self, *, statuses: Sequence[Status] | None = None) -> list[TaskSummary]:
|
|
628
|
+
"""Return task summaries ordered by their allocated task sequence."""
|
|
629
|
+
return [TaskSummary.from_task(task) for task in self._reader.list_tasks(statuses=statuses)]
|
|
630
|
+
|
|
631
|
+
def command_history(
|
|
632
|
+
self,
|
|
633
|
+
*,
|
|
634
|
+
expanded: bool = False,
|
|
635
|
+
limit: int | None = None,
|
|
636
|
+
offset: int = 0,
|
|
637
|
+
status: CommandStatus | None = None,
|
|
638
|
+
) -> CommandHistory:
|
|
639
|
+
"""Return one bounded newest-first page, preserving stored command outcomes."""
|
|
640
|
+
default_limit = PROJECT_COMMAND_HISTORY_LIMIT if expanded else RECENT_COMMAND_HISTORY_LIMIT
|
|
641
|
+
page_limit = default_limit if limit is None else limit
|
|
642
|
+
page = self._reader.command_history(limit=page_limit, offset=offset, status=status)
|
|
643
|
+
return CommandHistory(
|
|
644
|
+
engine_up=self._engine_up(),
|
|
645
|
+
entries=[self._command_entry(row) for row in page.entries],
|
|
646
|
+
offset=page.offset,
|
|
647
|
+
limit=page.limit,
|
|
648
|
+
total_count=page.total_count,
|
|
649
|
+
has_more=page.has_more,
|
|
650
|
+
status=status,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def command_status(self, command_id: int) -> CommandHistoryEntry | None:
|
|
654
|
+
"""Read one durable command status by id without creating or acknowledging it."""
|
|
655
|
+
row = self._reader.command_status(command_id)
|
|
656
|
+
return self._command_entry(row) if row is not None else None
|
|
657
|
+
|
|
658
|
+
def pending_command(self, command: Command) -> CommandHistoryEntry | None:
|
|
659
|
+
"""Find an identical pending command without writing or acknowledging it."""
|
|
660
|
+
row = self._reader.pending_command(command)
|
|
661
|
+
return self._command_entry(row) if row is not None else None
|
|
662
|
+
|
|
663
|
+
def _command_entry(self, row: StoredCommand) -> CommandHistoryEntry:
|
|
664
|
+
return CommandHistoryEntry(
|
|
665
|
+
id=row.id,
|
|
666
|
+
action=row.command.kind.value.replace("_", " ").capitalize(),
|
|
667
|
+
target=_command_target(row.command, self._project),
|
|
668
|
+
status=row.status,
|
|
669
|
+
result=row.result,
|
|
670
|
+
timestamp=row.ts,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
def search_task_targets(
|
|
674
|
+
self,
|
|
675
|
+
search: str,
|
|
676
|
+
*,
|
|
677
|
+
limit: int,
|
|
678
|
+
offset: int,
|
|
679
|
+
selected_task_id: str | None = None,
|
|
680
|
+
) -> TaskTargetSearchPage:
|
|
681
|
+
"""Read a bounded ID/title target page and retain a selected target outside the match."""
|
|
682
|
+
page = self._reader.task_browser_page(
|
|
683
|
+
category=TASK_TARGET_SEARCH_CATEGORY,
|
|
684
|
+
search=search,
|
|
685
|
+
limit=limit,
|
|
686
|
+
offset=offset,
|
|
687
|
+
)
|
|
688
|
+
tasks = tuple(TaskSummary.from_task(task) for task in page.tasks)
|
|
689
|
+
selected_task = None
|
|
690
|
+
if selected_task_id is not None and all(task.id != selected_task_id for task in tasks):
|
|
691
|
+
task = self._reader.get_task(selected_task_id)
|
|
692
|
+
if task is not None:
|
|
693
|
+
selected_task = TaskSummary.from_task(task)
|
|
694
|
+
return TaskTargetSearchPage(
|
|
695
|
+
tasks=tasks,
|
|
696
|
+
selected_task=selected_task,
|
|
697
|
+
matching_count=page.matching_count,
|
|
698
|
+
project_count=page.project_count,
|
|
699
|
+
has_more=offset + len(tasks) < page.matching_count,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
def task_action_role(self, task_id: str) -> Role:
|
|
703
|
+
"""Return the role at this task's current or first scheduled step."""
|
|
704
|
+
task = self._reader.get_task(task_id)
|
|
705
|
+
if task is None:
|
|
706
|
+
return Role.LEADER
|
|
707
|
+
return task_acting_role(task)
|
|
708
|
+
|
|
709
|
+
def get_task(self, task_id: str) -> TaskDetail | None:
|
|
710
|
+
"""Return one task, its runs and its event timeline, or `None` when absent."""
|
|
711
|
+
task = self._reader.get_task(task_id)
|
|
712
|
+
if task is None:
|
|
713
|
+
return None
|
|
714
|
+
runs = self._reader.list_runs(task_id)
|
|
715
|
+
return TaskDetail(
|
|
716
|
+
task=task,
|
|
717
|
+
events=self._reader.events_since(0, task_id=task_id),
|
|
718
|
+
reports=self._reports(task_id),
|
|
719
|
+
branches=[task.repo.branch] if task.repo is not None else [],
|
|
720
|
+
sessions=runs,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
async def get_task_detail(self, task_id: str) -> TaskDetail | None:
|
|
724
|
+
"""Read bounded task events and load its file evidence away from the UI loop."""
|
|
725
|
+
task = self._reader.get_task(task_id)
|
|
726
|
+
if task is None:
|
|
727
|
+
return None
|
|
728
|
+
runs = self._reader.list_runs(task_id)
|
|
729
|
+
events = self._reader.latest_events(TASK_DETAIL_EVENT_LIMIT, task_id=task_id)
|
|
730
|
+
roles = tuple(dict.fromkeys(run.role.value for run in runs))
|
|
731
|
+
data_version = self._reader.data_version()
|
|
732
|
+
cached = self._task_detail_file_cache
|
|
733
|
+
signature = cached.signature if cached is not None else ()
|
|
734
|
+
if cached is not None and cached.task_id == task_id and cached.roles == roles:
|
|
735
|
+
if cached.data_version != data_version:
|
|
736
|
+
signature = await asyncio.to_thread(self._task_file_signature, task_id)
|
|
737
|
+
if signature == cached.signature:
|
|
738
|
+
static_files = cached.static_files
|
|
739
|
+
self._task_detail_file_cache = TaskFileCache(
|
|
740
|
+
task_id,
|
|
741
|
+
data_version,
|
|
742
|
+
roles,
|
|
743
|
+
signature,
|
|
744
|
+
static_files,
|
|
745
|
+
)
|
|
746
|
+
else:
|
|
747
|
+
signature, static_files = await asyncio.to_thread(
|
|
748
|
+
self._task_static_files_with_signature, task_id
|
|
749
|
+
)
|
|
750
|
+
self._task_detail_file_cache = TaskFileCache(
|
|
751
|
+
task_id,
|
|
752
|
+
data_version,
|
|
753
|
+
roles,
|
|
754
|
+
signature,
|
|
755
|
+
static_files,
|
|
756
|
+
)
|
|
757
|
+
else:
|
|
758
|
+
signature, static_files = await asyncio.to_thread(
|
|
759
|
+
self._task_static_files_with_signature, task_id
|
|
760
|
+
)
|
|
761
|
+
self._task_detail_file_cache = TaskFileCache(
|
|
762
|
+
task_id,
|
|
763
|
+
data_version,
|
|
764
|
+
roles,
|
|
765
|
+
signature,
|
|
766
|
+
static_files,
|
|
767
|
+
)
|
|
768
|
+
reports, recorded_facts, facts_turn, evidence_errors = static_files
|
|
769
|
+
return TaskDetail(
|
|
770
|
+
task=task,
|
|
771
|
+
events=events,
|
|
772
|
+
reports=reports,
|
|
773
|
+
recorded_facts=recorded_facts,
|
|
774
|
+
facts_turn=facts_turn,
|
|
775
|
+
evidence_errors=evidence_errors,
|
|
776
|
+
branches=[task.repo.branch] if task.repo is not None else [],
|
|
777
|
+
sessions=runs,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
async def task_timeline(
|
|
781
|
+
self,
|
|
782
|
+
task_id: str,
|
|
783
|
+
*,
|
|
784
|
+
limit: int,
|
|
785
|
+
cursor: str | None = None,
|
|
786
|
+
role: str | None = None,
|
|
787
|
+
turn: str | None = None,
|
|
788
|
+
category: str = TIMELINE_DEFAULT_CATEGORY,
|
|
789
|
+
scan_limit: int,
|
|
790
|
+
) -> TaskTimelinePage | None:
|
|
791
|
+
"""Read a bounded, stable page across task events and normalized signal files."""
|
|
792
|
+
if not 0 < limit <= MAX_TIMELINE_PAGE_SIZE:
|
|
793
|
+
message = f"task evidence page size must be between 1 and {MAX_TIMELINE_PAGE_SIZE}"
|
|
794
|
+
raise ValueError(message)
|
|
795
|
+
if not 0 < scan_limit <= MAX_TIMELINE_SIGNAL_SCAN_ROWS:
|
|
796
|
+
message = (
|
|
797
|
+
"task evidence source scan size must be between 1 and "
|
|
798
|
+
f"{MAX_TIMELINE_SIGNAL_SCAN_ROWS}"
|
|
799
|
+
)
|
|
800
|
+
raise ValueError(message)
|
|
801
|
+
if category not in TIMELINE_CATEGORIES:
|
|
802
|
+
message = f"unknown task evidence category: {category}"
|
|
803
|
+
raise ValueError(message)
|
|
804
|
+
task = self._reader.get_task(task_id)
|
|
805
|
+
if task is None:
|
|
806
|
+
return None
|
|
807
|
+
filters = TimelineFilters(role=role, turn=turn, category=category)
|
|
808
|
+
roles = _timeline_roles(self._reader.list_runs(task_id), role)
|
|
809
|
+
paths = {item: view_log_path(run_dir(self._project_root, task_id), item) for item in roles}
|
|
810
|
+
current = await self._timeline_cursor(cursor, filters, roles, paths)
|
|
811
|
+
if cursor is not None:
|
|
812
|
+
roles = tuple(sorted(current.signal_before))
|
|
813
|
+
event_rows = self._timeline_events(
|
|
814
|
+
task_id, limit, current, role=role, turn=turn, category=category
|
|
815
|
+
)
|
|
816
|
+
signal_batches = await self._timeline_signals(
|
|
817
|
+
paths=paths,
|
|
818
|
+
roles=roles,
|
|
819
|
+
limit=limit,
|
|
820
|
+
cursor=current,
|
|
821
|
+
filters=filters,
|
|
822
|
+
scan_limit=scan_limit,
|
|
823
|
+
)
|
|
824
|
+
return _build_timeline_page(
|
|
825
|
+
limit=limit,
|
|
826
|
+
current=current,
|
|
827
|
+
filters=filters,
|
|
828
|
+
event_rows=event_rows,
|
|
829
|
+
signal_batches=signal_batches,
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
async def _timeline_cursor(
|
|
833
|
+
self,
|
|
834
|
+
token: str | None,
|
|
835
|
+
filters: TimelineFilters,
|
|
836
|
+
roles: Sequence[str],
|
|
837
|
+
paths: dict[str, Path],
|
|
838
|
+
) -> TimelineCursor:
|
|
839
|
+
if token is None:
|
|
840
|
+
return await asyncio.to_thread(initial_cursor, paths)
|
|
841
|
+
return decode_cursor(token, filters=filters, roles=roles)
|
|
842
|
+
|
|
843
|
+
def _timeline_events(
|
|
844
|
+
self,
|
|
845
|
+
task_id: str,
|
|
846
|
+
limit: int,
|
|
847
|
+
cursor: TimelineCursor,
|
|
848
|
+
*,
|
|
849
|
+
role: str | None,
|
|
850
|
+
turn: str | None,
|
|
851
|
+
category: str,
|
|
852
|
+
) -> list[Event]:
|
|
853
|
+
if not event_category_is_requested(category):
|
|
854
|
+
return []
|
|
855
|
+
return self._reader.task_timeline_events(
|
|
856
|
+
task_id,
|
|
857
|
+
limit=limit + 1,
|
|
858
|
+
before=cursor.event_before,
|
|
859
|
+
kinds=_timeline_event_kinds(category),
|
|
860
|
+
role=role,
|
|
861
|
+
turn=turn,
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
async def _timeline_signals(
|
|
865
|
+
self,
|
|
866
|
+
*,
|
|
867
|
+
paths: dict[str, Path],
|
|
868
|
+
roles: Sequence[str],
|
|
869
|
+
limit: int,
|
|
870
|
+
cursor: TimelineCursor,
|
|
871
|
+
filters: TimelineFilters,
|
|
872
|
+
scan_limit: int,
|
|
873
|
+
) -> dict[str, SignalBatch]:
|
|
874
|
+
if not signal_category_is_requested(filters.category):
|
|
875
|
+
return {}
|
|
876
|
+
loaded = await asyncio.gather(
|
|
877
|
+
*(
|
|
878
|
+
asyncio.to_thread(
|
|
879
|
+
read_signal_batch,
|
|
880
|
+
paths[item],
|
|
881
|
+
role=item,
|
|
882
|
+
before_offset=cursor.signal_before[item],
|
|
883
|
+
filters=filters,
|
|
884
|
+
limit=limit + 1,
|
|
885
|
+
scan_limit=scan_limit,
|
|
886
|
+
)
|
|
887
|
+
for item in roles
|
|
888
|
+
)
|
|
889
|
+
)
|
|
890
|
+
return dict(zip(roles, loaded, strict=True))
|
|
891
|
+
|
|
892
|
+
def get_task_state(self, task_id: str) -> Task | None:
|
|
893
|
+
"""Return current task state without loading report or signal files."""
|
|
894
|
+
return self._reader.get_task(task_id)
|
|
895
|
+
|
|
896
|
+
def task_action_availability(
|
|
897
|
+
self, task_id: str, role: Role | None
|
|
898
|
+
) -> tuple[TaskActionAvailability, ...]:
|
|
899
|
+
"""Project task command availability and disabled reasons from current state."""
|
|
900
|
+
task = self._reader.get_task(task_id)
|
|
901
|
+
if task is None:
|
|
902
|
+
return tuple(
|
|
903
|
+
TaskActionAvailability(action, TASK_ACTION_MISSING) for action in OperatorTaskAction
|
|
904
|
+
)
|
|
905
|
+
runs = self._reader.list_runs(task_id)
|
|
906
|
+
roles = (role,) if role is not None else TASK_ACTION_ROLES
|
|
907
|
+
role_states = tuple(self._task_action_reasons(task, runs, candidate) for candidate in roles)
|
|
908
|
+
if role is None:
|
|
909
|
+
return tuple(
|
|
910
|
+
TaskActionAvailability(
|
|
911
|
+
action,
|
|
912
|
+
next(
|
|
913
|
+
(states[action] for states in role_states if states[action] is None),
|
|
914
|
+
role_states[0][action],
|
|
915
|
+
),
|
|
916
|
+
)
|
|
917
|
+
for action in OperatorTaskAction
|
|
918
|
+
)
|
|
919
|
+
return tuple(
|
|
920
|
+
TaskActionAvailability(action, role_states[0][action]) for action in OperatorTaskAction
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
def _task_action_reasons(
|
|
924
|
+
self, task: Task, runs: Sequence[Run], role: Role
|
|
925
|
+
) -> dict[OperatorTaskAction, str | None]:
|
|
926
|
+
role_is_taken_over = any(
|
|
927
|
+
run.role is role and run.status is RunStatus.TAKEN_OVER for run in runs
|
|
928
|
+
)
|
|
929
|
+
any_role_taken_over = any(run.status is RunStatus.TAKEN_OVER for run in runs)
|
|
930
|
+
role_has_live_session = any(
|
|
931
|
+
run.role is role and run.status is RunStatus.LIVE and run.session_id is not None
|
|
932
|
+
for run in runs
|
|
933
|
+
)
|
|
934
|
+
terminal = task.status in (Status.DONE, Status.CANCELLED)
|
|
935
|
+
pane_missing = role not in task.panes
|
|
936
|
+
return {
|
|
937
|
+
OperatorTaskAction.PRIORITIZE: (
|
|
938
|
+
None if task.status is Status.QUEUED else TASK_ACTION_NOT_QUEUED
|
|
939
|
+
),
|
|
940
|
+
OperatorTaskAction.PAUSE: TASK_ACTION_TERMINAL if terminal else None,
|
|
941
|
+
OperatorTaskAction.PAUSE_NOW: TASK_ACTION_TERMINAL if terminal else None,
|
|
942
|
+
OperatorTaskAction.RESUME: (
|
|
943
|
+
TASK_ACTION_TERMINAL
|
|
944
|
+
if terminal
|
|
945
|
+
else TASK_ACTION_OTHER_ROLE_TAKEN_OVER
|
|
946
|
+
if any_role_taken_over
|
|
947
|
+
else TASK_ACTION_NO_HOLD
|
|
948
|
+
if not task.holds
|
|
949
|
+
else None
|
|
950
|
+
),
|
|
951
|
+
OperatorTaskAction.TAKE_OVER: (
|
|
952
|
+
TASK_ACTION_TAKEN_OVER
|
|
953
|
+
if role_is_taken_over
|
|
954
|
+
else TASK_ACTION_TERMINAL
|
|
955
|
+
if terminal
|
|
956
|
+
else TASK_ACTION_ROLE_MISSING
|
|
957
|
+
if pane_missing
|
|
958
|
+
else TASK_ACTION_SESSION_MISSING
|
|
959
|
+
if not role_has_live_session
|
|
960
|
+
else None
|
|
961
|
+
),
|
|
962
|
+
OperatorTaskAction.HAND_BACK: (
|
|
963
|
+
TASK_ACTION_NOT_TAKEN_OVER
|
|
964
|
+
if not role_is_taken_over
|
|
965
|
+
else TASK_ACTION_ROLE_MISSING
|
|
966
|
+
if pane_missing
|
|
967
|
+
else None
|
|
968
|
+
),
|
|
969
|
+
OperatorTaskAction.CANCEL: TASK_ACTION_TERMINAL if terminal else None,
|
|
970
|
+
OperatorTaskAction.RETRY: TASK_ACTION_TERMINAL if terminal else None,
|
|
971
|
+
OperatorTaskAction.RUNG: TASK_ACTION_TERMINAL if terminal else None,
|
|
972
|
+
OperatorTaskAction.MESSAGE: TASK_ACTION_TERMINAL if terminal else None,
|
|
973
|
+
OperatorTaskAction.PEEK: None,
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
def task_role_for_pane(self, pane_id: str) -> tuple[str, Role] | None:
|
|
977
|
+
"""Return the recorded task and role whose pane matches an invoking tmux pane."""
|
|
978
|
+
for task in self._reader.list_tasks(statuses=TASK_PANE_CONTEXT_STATUSES):
|
|
979
|
+
for role, recorded_pane in task.panes.items():
|
|
980
|
+
if recorded_pane == pane_id:
|
|
981
|
+
return task.id, role
|
|
982
|
+
return None
|
|
983
|
+
|
|
984
|
+
def task_worktree_path(self, task_id: str) -> Path:
|
|
985
|
+
"""Return the recorded layout location used for this task's worktree."""
|
|
986
|
+
return task_root(self._project_root, task_id)
|
|
987
|
+
|
|
988
|
+
def list_escalations(self) -> list[EscalationView]:
|
|
989
|
+
"""Return every durable open decision with packet text or its read error."""
|
|
990
|
+
escalations = self._reader.list_open_escalations()
|
|
991
|
+
escalations.sort(key=lambda item: (item.created_at, item.id))
|
|
992
|
+
packet_paths = {self._packet_path(item) for item in escalations}
|
|
993
|
+
self._escalation_packet_cache = {
|
|
994
|
+
path: cached
|
|
995
|
+
for path, cached in self._escalation_packet_cache.items()
|
|
996
|
+
if path in packet_paths
|
|
997
|
+
}
|
|
998
|
+
return [self._escalation_view(escalation) for escalation in escalations]
|
|
999
|
+
|
|
1000
|
+
def get_escalation(self, escalation_id: str) -> EscalationView | None:
|
|
1001
|
+
"""Return one escalation and packet text, or `None` when absent."""
|
|
1002
|
+
escalation = self._reader.get_escalation(escalation_id)
|
|
1003
|
+
if escalation is None:
|
|
1004
|
+
return None
|
|
1005
|
+
return self._escalation_view(escalation)
|
|
1006
|
+
|
|
1007
|
+
def escalation_resolution_disabled_reason(self, escalation_id: str) -> str | None:
|
|
1008
|
+
"""Project whether current durable escalation and task state allow resolution."""
|
|
1009
|
+
escalation = self._reader.get_escalation(escalation_id)
|
|
1010
|
+
if escalation is None:
|
|
1011
|
+
return ESCALATION_STATUS_NOT_OPEN.format(escalation_id=escalation_id)
|
|
1012
|
+
task = self._reader.get_task(escalation.task_id)
|
|
1013
|
+
task_state = task.status.value if task is not None else "missing"
|
|
1014
|
+
if escalation.status is not EscalationStatus.OPEN:
|
|
1015
|
+
return ESCALATION_STATUS_RESOLVED.format(
|
|
1016
|
+
escalation_id=escalation.id,
|
|
1017
|
+
task_id=escalation.task_id,
|
|
1018
|
+
status=task_state,
|
|
1019
|
+
)
|
|
1020
|
+
if task is None:
|
|
1021
|
+
return ESCALATION_STATUS_MISSING.format(
|
|
1022
|
+
task_id=escalation.task_id,
|
|
1023
|
+
escalation_id=escalation.id,
|
|
1024
|
+
)
|
|
1025
|
+
if task.status is not Status.ESCALATED:
|
|
1026
|
+
return ESCALATION_STATUS_STALE.format(
|
|
1027
|
+
task_id=escalation.task_id,
|
|
1028
|
+
status=task_state,
|
|
1029
|
+
escalation_id=escalation.id,
|
|
1030
|
+
)
|
|
1031
|
+
return None
|
|
1032
|
+
|
|
1033
|
+
def _escalation_view(self, escalation: Escalation) -> EscalationView:
|
|
1034
|
+
try:
|
|
1035
|
+
packet = self._packet_text(escalation)
|
|
1036
|
+
except OperatorQueryError as error:
|
|
1037
|
+
return EscalationView(
|
|
1038
|
+
escalation=escalation,
|
|
1039
|
+
packet=None,
|
|
1040
|
+
packet_error=str(error),
|
|
1041
|
+
)
|
|
1042
|
+
parsed = parse_escalation_packet(packet)
|
|
1043
|
+
request = parsed.agent_request
|
|
1044
|
+
return EscalationView(
|
|
1045
|
+
escalation=escalation,
|
|
1046
|
+
packet=packet,
|
|
1047
|
+
question=_escalation_question(parsed),
|
|
1048
|
+
agent_options=request.options if request is not None else [],
|
|
1049
|
+
resolutions=_escalation_resolutions(parsed, escalation),
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
def peek_session(self, task_id: str, role: Role | None = None) -> PeekView | None:
|
|
1053
|
+
"""Return the last message, usage and recent view lines of one or every task role."""
|
|
1054
|
+
task = self._reader.get_task(task_id)
|
|
1055
|
+
if task is None:
|
|
1056
|
+
return None
|
|
1057
|
+
latest = {run.role: run for run in reversed(self._reader.list_runs(task_id))}
|
|
1058
|
+
roles = [role] if role is not None else list(latest)
|
|
1059
|
+
if not roles:
|
|
1060
|
+
message = f"{task_id} has no agent sessions"
|
|
1061
|
+
raise OperatorQueryError(message)
|
|
1062
|
+
panes: dict[str, PeekPane] = {}
|
|
1063
|
+
for peek_role in roles:
|
|
1064
|
+
run = latest.get(peek_role)
|
|
1065
|
+
if run is None:
|
|
1066
|
+
message = f"{task_id} has no {peek_role.value} session"
|
|
1067
|
+
raise OperatorQueryError(message)
|
|
1068
|
+
panes[peek_role.value] = PeekPane(
|
|
1069
|
+
agent=run.agent,
|
|
1070
|
+
last_message=run.last_message,
|
|
1071
|
+
usage=run.usage,
|
|
1072
|
+
view=_view_tail(view_log_path(run_dir(self._project_root, task_id), run.role)),
|
|
1073
|
+
)
|
|
1074
|
+
return PeekView(task_id=task_id, panes=panes)
|
|
1075
|
+
|
|
1076
|
+
def _packet_text(self, escalation: Escalation) -> str:
|
|
1077
|
+
packet_path = self._packet_path(escalation)
|
|
1078
|
+
try:
|
|
1079
|
+
info = packet_path.stat()
|
|
1080
|
+
except FileNotFoundError:
|
|
1081
|
+
message = f"escalation packet is missing: {packet_path}"
|
|
1082
|
+
raise OperatorQueryError(message) from None
|
|
1083
|
+
except OSError as error:
|
|
1084
|
+
message = f"cannot inspect escalation packet {packet_path}: {error}"
|
|
1085
|
+
raise OperatorQueryError(message) from error
|
|
1086
|
+
cached = self._escalation_packet_cache.get(packet_path)
|
|
1087
|
+
if (
|
|
1088
|
+
cached is not None
|
|
1089
|
+
and cached.modified_ns == info.st_mtime_ns
|
|
1090
|
+
and cached.changed_ns == info.st_ctime_ns
|
|
1091
|
+
and cached.size == info.st_size
|
|
1092
|
+
):
|
|
1093
|
+
return cached.text
|
|
1094
|
+
try:
|
|
1095
|
+
packet = packet_path.read_text()
|
|
1096
|
+
except (OSError, UnicodeError) as error:
|
|
1097
|
+
message = f"cannot read escalation packet {packet_path}: {error}"
|
|
1098
|
+
raise OperatorQueryError(message) from error
|
|
1099
|
+
self._escalation_packet_cache[packet_path] = PacketFileCache(
|
|
1100
|
+
info.st_mtime_ns,
|
|
1101
|
+
info.st_ctime_ns,
|
|
1102
|
+
info.st_size,
|
|
1103
|
+
packet,
|
|
1104
|
+
)
|
|
1105
|
+
return packet
|
|
1106
|
+
|
|
1107
|
+
def _packet_path(self, escalation: Escalation) -> Path:
|
|
1108
|
+
packet_path = Path(escalation.packet_path)
|
|
1109
|
+
return packet_path if packet_path.is_absolute() else self._project_root / packet_path
|
|
1110
|
+
|
|
1111
|
+
def _reports(self, task_id: str) -> list[ReportRow]:
|
|
1112
|
+
task_turns_dir = turns_dir(self._project_root, task_id)
|
|
1113
|
+
if not task_turns_dir.is_dir():
|
|
1114
|
+
return []
|
|
1115
|
+
reports: list[ReportRow] = []
|
|
1116
|
+
for turn_dir in ordered_turn_dirs(task_turns_dir):
|
|
1117
|
+
loaded = load_turn_report(turn_dir)
|
|
1118
|
+
if loaded is not None:
|
|
1119
|
+
reports.append(
|
|
1120
|
+
ReportRow(
|
|
1121
|
+
turn=turn_dir.name,
|
|
1122
|
+
kind=loaded.kind,
|
|
1123
|
+
data=loaded.report,
|
|
1124
|
+
)
|
|
1125
|
+
)
|
|
1126
|
+
return reports
|
|
1127
|
+
|
|
1128
|
+
def _task_static_files_with_signature(
|
|
1129
|
+
self,
|
|
1130
|
+
task_id: str,
|
|
1131
|
+
) -> tuple[TaskFileSignature, TaskStaticFileEvidence]:
|
|
1132
|
+
return self._task_file_signature(task_id), self._task_static_files(task_id)
|
|
1133
|
+
|
|
1134
|
+
def _task_file_signature(self, task_id: str) -> TaskFileSignature:
|
|
1135
|
+
task_turns_dir = turns_dir(self._project_root, task_id)
|
|
1136
|
+
turn_dirs, errors = self._task_turn_dirs(task_id)
|
|
1137
|
+
if turn_dirs is None:
|
|
1138
|
+
return ((str(task_turns_dir), None, None, None, "; ".join(errors)),)
|
|
1139
|
+
filenames = (
|
|
1140
|
+
FACTS_FILENAME,
|
|
1141
|
+
REPORT_KIND_FILENAME,
|
|
1142
|
+
REPORT_FILENAME,
|
|
1143
|
+
HELPER_REPORT_FILENAME,
|
|
1144
|
+
ENGINE_VERDICT_FILENAME,
|
|
1145
|
+
)
|
|
1146
|
+
signature: list[FileIdentity] = []
|
|
1147
|
+
for turn_dir in turn_dirs:
|
|
1148
|
+
for filename in filenames:
|
|
1149
|
+
path = turn_dir / filename
|
|
1150
|
+
try:
|
|
1151
|
+
info = path.stat()
|
|
1152
|
+
except FileNotFoundError:
|
|
1153
|
+
signature.append((str(path), None, None, None, None))
|
|
1154
|
+
except OSError as error:
|
|
1155
|
+
signature.append((str(path), None, None, None, str(error)))
|
|
1156
|
+
else:
|
|
1157
|
+
signature.append(
|
|
1158
|
+
(
|
|
1159
|
+
str(path),
|
|
1160
|
+
info.st_mtime_ns,
|
|
1161
|
+
info.st_ctime_ns,
|
|
1162
|
+
info.st_size,
|
|
1163
|
+
None,
|
|
1164
|
+
)
|
|
1165
|
+
)
|
|
1166
|
+
return tuple(signature)
|
|
1167
|
+
|
|
1168
|
+
def _task_static_files(self, task_id: str) -> TaskStaticFileEvidence:
|
|
1169
|
+
turn_dirs, errors = self._task_turn_dirs(task_id)
|
|
1170
|
+
if turn_dirs is None:
|
|
1171
|
+
return [], None, None, errors
|
|
1172
|
+
reports, report_errors = self._task_reports(turn_dirs)
|
|
1173
|
+
recorded_facts, facts_turn, fact_errors = _task_facts(turn_dirs)
|
|
1174
|
+
return reports, recorded_facts, facts_turn, [*errors, *report_errors, *fact_errors]
|
|
1175
|
+
|
|
1176
|
+
def _task_all_export_files(self, task_id: str) -> TaskExportFiles:
|
|
1177
|
+
turn_dirs, directory_errors = self._task_turn_dirs(task_id)
|
|
1178
|
+
errors = [("reports", error) for error in directory_errors]
|
|
1179
|
+
if turn_dirs is None:
|
|
1180
|
+
return TaskExportFiles(reports=(), facts=(), errors=tuple(errors))
|
|
1181
|
+
reports: list[ReportRow] = []
|
|
1182
|
+
facts: list[tuple[str, str]] = []
|
|
1183
|
+
missing_report_turns: list[str] = []
|
|
1184
|
+
missing_fact_turns: list[str] = []
|
|
1185
|
+
for turn_dir in turn_dirs:
|
|
1186
|
+
turn_reports, report_errors = _task_turn_reports(turn_dir)
|
|
1187
|
+
reports.extend(turn_reports)
|
|
1188
|
+
errors.extend(("reports", error) for error in report_errors)
|
|
1189
|
+
if not turn_reports and not report_errors:
|
|
1190
|
+
missing_report_turns.append(turn_dir.name)
|
|
1191
|
+
facts_path = turn_dir / FACTS_FILENAME
|
|
1192
|
+
try:
|
|
1193
|
+
facts_content = facts_path.read_text()
|
|
1194
|
+
except FileNotFoundError:
|
|
1195
|
+
missing_fact_turns.append(turn_dir.name)
|
|
1196
|
+
continue
|
|
1197
|
+
except (OSError, UnicodeError) as error:
|
|
1198
|
+
errors.append(("facts", TASK_FACTS_ERROR.format(error=error)))
|
|
1199
|
+
else:
|
|
1200
|
+
facts.append((turn_dir.name, facts_content))
|
|
1201
|
+
if reports:
|
|
1202
|
+
errors.extend(
|
|
1203
|
+
("reports", TASK_REPORT_TURN_MISSING.format(turn=turn))
|
|
1204
|
+
for turn in missing_report_turns
|
|
1205
|
+
)
|
|
1206
|
+
if facts:
|
|
1207
|
+
errors.extend(
|
|
1208
|
+
("facts", TASK_FACTS_TURN_MISSING.format(turn=turn)) for turn in missing_fact_turns
|
|
1209
|
+
)
|
|
1210
|
+
return TaskExportFiles(
|
|
1211
|
+
reports=tuple(reports),
|
|
1212
|
+
facts=tuple(facts),
|
|
1213
|
+
errors=tuple(errors),
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
def _task_turn_dirs(self, task_id: str) -> tuple[list[Path] | None, list[str]]:
|
|
1217
|
+
task_turns_dir = turns_dir(self._project_root, task_id)
|
|
1218
|
+
try:
|
|
1219
|
+
task_turns_dir.stat()
|
|
1220
|
+
except FileNotFoundError:
|
|
1221
|
+
return None, []
|
|
1222
|
+
except OSError as error:
|
|
1223
|
+
return None, [TASK_REPORT_ERROR.format(error=error)]
|
|
1224
|
+
if not task_turns_dir.is_dir():
|
|
1225
|
+
return None, [TASK_REPORT_ERROR.format(error=f"not a directory: {task_turns_dir}")]
|
|
1226
|
+
try:
|
|
1227
|
+
return ordered_turn_dirs(task_turns_dir), []
|
|
1228
|
+
except (OSError, ValueError) as error:
|
|
1229
|
+
return None, [TASK_REPORT_ERROR.format(error=error)]
|
|
1230
|
+
|
|
1231
|
+
def _task_reports(self, turn_dirs: Sequence[Path]) -> tuple[list[ReportRow], list[str]]:
|
|
1232
|
+
latest_agent: ReportRow | None = None
|
|
1233
|
+
latest_verdict: ReportRow | None = None
|
|
1234
|
+
latest_triage: ReportRow | None = None
|
|
1235
|
+
errors: list[str] = []
|
|
1236
|
+
for turn_dir in reversed(turn_dirs):
|
|
1237
|
+
turn_reports, turn_errors = _task_turn_reports(turn_dir)
|
|
1238
|
+
errors.extend(turn_errors)
|
|
1239
|
+
for report in turn_reports:
|
|
1240
|
+
if report.source == AGENT_REPORT_SOURCE and latest_agent is None:
|
|
1241
|
+
latest_agent = report
|
|
1242
|
+
if report.kind is ReportKind.TRIAGE and latest_triage is None:
|
|
1243
|
+
latest_triage = report
|
|
1244
|
+
if latest_verdict is None:
|
|
1245
|
+
latest_verdict = next(
|
|
1246
|
+
(
|
|
1247
|
+
report
|
|
1248
|
+
for report in turn_reports
|
|
1249
|
+
if report.source == ENGINE_REPORT_SOURCE
|
|
1250
|
+
and report.kind is ReportKind.VERDICT
|
|
1251
|
+
),
|
|
1252
|
+
None,
|
|
1253
|
+
) or next(
|
|
1254
|
+
(
|
|
1255
|
+
report
|
|
1256
|
+
for report in turn_reports
|
|
1257
|
+
if report.source == AGENT_REPORT_SOURCE
|
|
1258
|
+
and report.kind is ReportKind.VERDICT
|
|
1259
|
+
),
|
|
1260
|
+
None,
|
|
1261
|
+
)
|
|
1262
|
+
if (
|
|
1263
|
+
latest_agent is not None
|
|
1264
|
+
and latest_verdict is not None
|
|
1265
|
+
and latest_triage is not None
|
|
1266
|
+
):
|
|
1267
|
+
break
|
|
1268
|
+
turn_order = {turn_dir.name: index for index, turn_dir in enumerate(turn_dirs)}
|
|
1269
|
+
reports = {
|
|
1270
|
+
(report.turn, report.kind, report.source): report
|
|
1271
|
+
for report in (latest_agent, latest_verdict, latest_triage)
|
|
1272
|
+
if report is not None
|
|
1273
|
+
}
|
|
1274
|
+
return sorted(reports.values(), key=lambda report: turn_order[report.turn]), errors
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _task_turn_reports(turn_dir: Path) -> tuple[list[ReportRow], list[str]]:
|
|
1278
|
+
reports: list[ReportRow] = []
|
|
1279
|
+
errors: list[str] = []
|
|
1280
|
+
try:
|
|
1281
|
+
loaded = load_turn_report(turn_dir)
|
|
1282
|
+
except (InvalidTurnReportError, OSError, ValueError) as error:
|
|
1283
|
+
errors.append(str(error))
|
|
1284
|
+
else:
|
|
1285
|
+
if loaded is not None:
|
|
1286
|
+
reports.append(
|
|
1287
|
+
ReportRow(
|
|
1288
|
+
turn=turn_dir.name,
|
|
1289
|
+
kind=loaded.kind,
|
|
1290
|
+
data=loaded.report,
|
|
1291
|
+
source=AGENT_REPORT_SOURCE,
|
|
1292
|
+
)
|
|
1293
|
+
)
|
|
1294
|
+
try:
|
|
1295
|
+
verdict = load_engine_verdict(turn_dir)
|
|
1296
|
+
except (InvalidTurnReportError, OSError, ValueError) as error:
|
|
1297
|
+
errors.append(str(error))
|
|
1298
|
+
else:
|
|
1299
|
+
if verdict is not None:
|
|
1300
|
+
reports.append(
|
|
1301
|
+
ReportRow(
|
|
1302
|
+
turn=turn_dir.name,
|
|
1303
|
+
kind=(
|
|
1304
|
+
ReportKind.FEEDBACK_VERDICT
|
|
1305
|
+
if isinstance(verdict, FeedbackVerdict)
|
|
1306
|
+
else ReportKind.VERDICT
|
|
1307
|
+
),
|
|
1308
|
+
data=verdict,
|
|
1309
|
+
source=ENGINE_REPORT_SOURCE,
|
|
1310
|
+
)
|
|
1311
|
+
)
|
|
1312
|
+
return reports, errors
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _task_facts(turn_dirs: Sequence[Path]) -> tuple[str | None, str | None, list[str]]:
|
|
1316
|
+
for turn_dir in reversed(turn_dirs):
|
|
1317
|
+
facts_path = turn_dir / FACTS_FILENAME
|
|
1318
|
+
try:
|
|
1319
|
+
facts = facts_path.read_text()
|
|
1320
|
+
except FileNotFoundError:
|
|
1321
|
+
continue
|
|
1322
|
+
except (OSError, UnicodeError) as error:
|
|
1323
|
+
return None, None, [TASK_FACTS_ERROR.format(error=error)]
|
|
1324
|
+
return facts, turn_dir.name, []
|
|
1325
|
+
return None, None, []
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _view_tail(path: Path) -> list[str]:
|
|
1329
|
+
if not path.is_file():
|
|
1330
|
+
return []
|
|
1331
|
+
try:
|
|
1332
|
+
return read_tail_lines(path, PEEK_TAIL_LINES)
|
|
1333
|
+
except OSError as error:
|
|
1334
|
+
message = f"cannot read view log {path}: {error}"
|
|
1335
|
+
raise OperatorQueryError(message) from error
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _timeline_roles(runs: Sequence[Run], role: str | None) -> tuple[str, ...]:
|
|
1339
|
+
if role is not None:
|
|
1340
|
+
return (Role(role).value,)
|
|
1341
|
+
return tuple(sorted({run.role.value for run in runs}))
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _build_timeline_page(
|
|
1345
|
+
*,
|
|
1346
|
+
limit: int,
|
|
1347
|
+
current: TimelineCursor,
|
|
1348
|
+
filters: TimelineFilters,
|
|
1349
|
+
event_rows: Sequence[Event],
|
|
1350
|
+
signal_batches: dict[str, SignalBatch],
|
|
1351
|
+
) -> TaskTimelinePage:
|
|
1352
|
+
streams: dict[str, list[TimelineItem]] = {}
|
|
1353
|
+
if event_rows:
|
|
1354
|
+
streams[TIMELINE_SOURCE_ENGINE] = [_event_timeline_item(event) for event in event_rows]
|
|
1355
|
+
streams.update(
|
|
1356
|
+
{
|
|
1357
|
+
f"{TIMELINE_SOURCE_AGENT}:{role}": list(batch.items)
|
|
1358
|
+
for role, batch in signal_batches.items()
|
|
1359
|
+
if batch.items
|
|
1360
|
+
}
|
|
1361
|
+
)
|
|
1362
|
+
merged = _merge_timeline_streams(streams, limit + 1)
|
|
1363
|
+
entries = merged[:limit]
|
|
1364
|
+
has_older = len(merged) > limit or any(batch.has_more for batch in signal_batches.values())
|
|
1365
|
+
older_cursor = (
|
|
1366
|
+
_next_timeline_cursor(current, entries, signal_batches, filters) if has_older else None
|
|
1367
|
+
)
|
|
1368
|
+
return TaskTimelinePage(
|
|
1369
|
+
entries=[_timeline_entry(item) for item in entries],
|
|
1370
|
+
older_cursor=older_cursor,
|
|
1371
|
+
has_older=has_older,
|
|
1372
|
+
filters={"role": filters.role, "turn": filters.turn, "category": filters.category},
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _next_timeline_cursor(
|
|
1377
|
+
current: TimelineCursor,
|
|
1378
|
+
entries: Sequence[TimelineItem],
|
|
1379
|
+
signal_batches: dict[str, SignalBatch],
|
|
1380
|
+
filters: TimelineFilters,
|
|
1381
|
+
) -> str:
|
|
1382
|
+
next_event = current.event_before
|
|
1383
|
+
next_signals = dict(current.signal_before)
|
|
1384
|
+
consumed_roles: set[str] = set()
|
|
1385
|
+
for item in entries:
|
|
1386
|
+
if item.event_id is not None and item.timestamp is not None:
|
|
1387
|
+
next_event = (format_timestamp(item.timestamp), item.event_id)
|
|
1388
|
+
elif item.role is not None:
|
|
1389
|
+
consumed_roles.add(item.role)
|
|
1390
|
+
next_signals[item.role] = (
|
|
1391
|
+
-1
|
|
1392
|
+
if item.identity.startswith(("missing:", TIMELINE_READ_ERROR_PREFIX))
|
|
1393
|
+
else item.signal_offset
|
|
1394
|
+
)
|
|
1395
|
+
for role, batch in signal_batches.items():
|
|
1396
|
+
if role not in consumed_roles:
|
|
1397
|
+
next_signals[role] = batch.replay_offset if batch.items else batch.scan_offset
|
|
1398
|
+
return encode_cursor(
|
|
1399
|
+
TimelineCursor(event_before=next_event, signal_before=next_signals), filters
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _timeline_event_kinds(category: str) -> tuple[EventKind, ...] | None:
|
|
1404
|
+
if category in {
|
|
1405
|
+
TIMELINE_ACTIVITY_CATEGORY,
|
|
1406
|
+
TIMELINE_ALL_CATEGORY,
|
|
1407
|
+
TIMELINE_ENGINE_CATEGORY,
|
|
1408
|
+
}:
|
|
1409
|
+
return None
|
|
1410
|
+
if category == TIMELINE_DECISION_CATEGORY:
|
|
1411
|
+
return tuple(EventKind(kind) for kind in sorted(TIMELINE_DECISION_KINDS))
|
|
1412
|
+
if category == TIMELINE_TRANSITION_CATEGORY:
|
|
1413
|
+
return tuple(kind for kind in EventKind if kind.value not in TIMELINE_DECISION_KINDS)
|
|
1414
|
+
return ()
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _event_timeline_item(event: Event) -> TimelineItem:
|
|
1418
|
+
role = event.data.get("role")
|
|
1419
|
+
turn = event.data.get("turn")
|
|
1420
|
+
return TimelineItem(
|
|
1421
|
+
identity=f"engine:{event.id}",
|
|
1422
|
+
timestamp=event.ts,
|
|
1423
|
+
source=TIMELINE_SOURCE_ENGINE,
|
|
1424
|
+
category=event_category(event.kind.value),
|
|
1425
|
+
summary=event.message,
|
|
1426
|
+
phrase=event_phrase(event),
|
|
1427
|
+
detail=json.dumps(
|
|
1428
|
+
event.data, ensure_ascii=False, sort_keys=True, indent=TIMELINE_JSON_INDENT
|
|
1429
|
+
),
|
|
1430
|
+
role=role if isinstance(role, str) else None,
|
|
1431
|
+
turn=turn if isinstance(turn, str) else None,
|
|
1432
|
+
event_id=event.id,
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _merge_timeline_streams(
|
|
1437
|
+
streams: dict[str, list[TimelineItem]], limit: int
|
|
1438
|
+
) -> list[TimelineItem]:
|
|
1439
|
+
offsets = dict.fromkeys(streams, 0)
|
|
1440
|
+
merged: list[TimelineItem] = []
|
|
1441
|
+
while len(merged) < limit:
|
|
1442
|
+
available = [
|
|
1443
|
+
(source, items[offsets[source]])
|
|
1444
|
+
for source, items in streams.items()
|
|
1445
|
+
if offsets[source] < len(items)
|
|
1446
|
+
]
|
|
1447
|
+
if not available:
|
|
1448
|
+
break
|
|
1449
|
+
dated = [item for _, item in available if item.timestamp is not None]
|
|
1450
|
+
if dated:
|
|
1451
|
+
newest = max(item.timestamp for item in dated if item.timestamp is not None)
|
|
1452
|
+
tied = [pair for pair in available if pair[1].timestamp == newest]
|
|
1453
|
+
else:
|
|
1454
|
+
tied = available
|
|
1455
|
+
source, item = min(tied, key=_timeline_tie_break)
|
|
1456
|
+
merged.append(item)
|
|
1457
|
+
offsets[source] += 1
|
|
1458
|
+
return merged
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _timeline_tie_break(pair: tuple[str, TimelineItem]) -> tuple[int, str, int]:
|
|
1462
|
+
source, item = pair
|
|
1463
|
+
source_rank = 0 if item.source == TIMELINE_SOURCE_ENGINE else 1
|
|
1464
|
+
stable_position = -(item.event_id or item.signal_offset or 0)
|
|
1465
|
+
return source_rank, item.role or source, stable_position
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _timeline_entry(item: TimelineItem) -> TaskTimelineEntry:
|
|
1469
|
+
return TaskTimelineEntry(
|
|
1470
|
+
identity=item.identity,
|
|
1471
|
+
timestamp=item.timestamp.isoformat() if item.timestamp is not None else None,
|
|
1472
|
+
timestamp_error=item.timestamp_error,
|
|
1473
|
+
source=item.source,
|
|
1474
|
+
category=item.category,
|
|
1475
|
+
summary=item.summary,
|
|
1476
|
+
phrase=item.phrase.text,
|
|
1477
|
+
glyph=item.phrase.glyph,
|
|
1478
|
+
tone=item.phrase.tone.value,
|
|
1479
|
+
detail=item.detail,
|
|
1480
|
+
role=item.role,
|
|
1481
|
+
turn=item.turn,
|
|
1482
|
+
event_id=item.event_id,
|
|
1483
|
+
incomplete=item.incomplete,
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def _command_target(command: Command, project: str) -> str:
|
|
1488
|
+
if isinstance(command, Submit):
|
|
1489
|
+
if command.task_id is None:
|
|
1490
|
+
message = "stored submit command has no allocated task id"
|
|
1491
|
+
raise ValueError(message)
|
|
1492
|
+
return command.task_id
|
|
1493
|
+
if isinstance(command, Resolve):
|
|
1494
|
+
return command.escalation_id
|
|
1495
|
+
if isinstance(command, Stop):
|
|
1496
|
+
return project
|
|
1497
|
+
if isinstance(command, (Say, SetRung, TakeOver, HandBack)):
|
|
1498
|
+
return f"{command.task_id} · {command.role.value}"
|
|
1499
|
+
return command.task_id
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def aggregate_usage(runs: Sequence[Run]) -> list[UsageView]:
|
|
1503
|
+
"""Aggregate recorded runs with the shared token and cost provenance rules."""
|
|
1504
|
+
by_agent: dict[str, list[Run]] = {}
|
|
1505
|
+
for run in runs:
|
|
1506
|
+
by_agent.setdefault(run.agent, []).append(run)
|
|
1507
|
+
views: list[UsageView] = []
|
|
1508
|
+
for agent in sorted(by_agent):
|
|
1509
|
+
agent_runs = by_agent[agent]
|
|
1510
|
+
reported_runs = [run for run in agent_runs if run.usage_reported is True]
|
|
1511
|
+
legacy_token_runs = [
|
|
1512
|
+
run
|
|
1513
|
+
for run in agent_runs
|
|
1514
|
+
if run.usage_reported is None and has_positive_token_evidence(run)
|
|
1515
|
+
]
|
|
1516
|
+
token_runs = [*reported_runs, *legacy_token_runs]
|
|
1517
|
+
cost_bases: set[CostBasis] = {
|
|
1518
|
+
run.usage.cost_basis for run in agent_runs if _has_priced_cost_evidence(run)
|
|
1519
|
+
}
|
|
1520
|
+
billed_runs = [
|
|
1521
|
+
run
|
|
1522
|
+
for run in agent_runs
|
|
1523
|
+
if _has_priced_cost_evidence(run) and run.usage.cost_basis == _COST_BASIS_BILLED
|
|
1524
|
+
]
|
|
1525
|
+
api_equivalent_runs = [
|
|
1526
|
+
run
|
|
1527
|
+
for run in agent_runs
|
|
1528
|
+
if _has_priced_cost_evidence(run) and run.usage.cost_basis == _COST_BASIS_API_EQUIVALENT
|
|
1529
|
+
]
|
|
1530
|
+
priced_run_count = len(billed_runs) + len(api_equivalent_runs)
|
|
1531
|
+
unknown_cost_runs = len(agent_runs) - priced_run_count
|
|
1532
|
+
cost_basis: CostBasis | Literal["mixed"]
|
|
1533
|
+
if unknown_cost_runs:
|
|
1534
|
+
cost_usd = None
|
|
1535
|
+
cost_basis = COST_BASIS_UNKNOWN
|
|
1536
|
+
elif len(cost_bases) > 1:
|
|
1537
|
+
cost_usd = None
|
|
1538
|
+
cost_basis = "mixed"
|
|
1539
|
+
else:
|
|
1540
|
+
cost_usd = _priced_total(billed_runs or api_equivalent_runs)
|
|
1541
|
+
cost_basis = next(iter(cost_bases)) if cost_bases else COST_BASIS_UNKNOWN
|
|
1542
|
+
views.append(
|
|
1543
|
+
UsageView(
|
|
1544
|
+
agent=agent,
|
|
1545
|
+
input_tokens=sum(run.usage.input_tokens for run in token_runs),
|
|
1546
|
+
output_tokens=sum(run.usage.output_tokens for run in token_runs),
|
|
1547
|
+
cache_read_tokens=sum(run.usage.cache_read_tokens for run in token_runs),
|
|
1548
|
+
cache_write_tokens=sum(run.usage.cache_write_tokens for run in token_runs),
|
|
1549
|
+
cost_usd=cost_usd,
|
|
1550
|
+
cost_basis=cost_basis,
|
|
1551
|
+
run_count=len(agent_runs),
|
|
1552
|
+
usage_reported_runs=len(reported_runs),
|
|
1553
|
+
legacy_token_runs=len(legacy_token_runs),
|
|
1554
|
+
billed_cost_usd=_priced_total(billed_runs),
|
|
1555
|
+
billed_cost_runs=len(billed_runs),
|
|
1556
|
+
legacy_billed_cost_runs=sum(run.usage_reported is None for run in billed_runs),
|
|
1557
|
+
api_equivalent_cost_usd=_priced_total(api_equivalent_runs),
|
|
1558
|
+
api_equivalent_cost_runs=len(api_equivalent_runs),
|
|
1559
|
+
legacy_api_equivalent_cost_runs=sum(
|
|
1560
|
+
run.usage_reported is None for run in api_equivalent_runs
|
|
1561
|
+
),
|
|
1562
|
+
unknown_cost_runs=unknown_cost_runs,
|
|
1563
|
+
)
|
|
1564
|
+
)
|
|
1565
|
+
return views
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def _has_priced_cost_evidence(run: Run) -> bool:
|
|
1569
|
+
return (
|
|
1570
|
+
run.usage_reported is not False
|
|
1571
|
+
and run.usage.cost_usd is not None
|
|
1572
|
+
and run.usage.cost_basis in (_COST_BASIS_BILLED, _COST_BASIS_API_EQUIVALENT)
|
|
1573
|
+
)
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _priced_total(runs: Sequence[Run]) -> float | None:
|
|
1577
|
+
if not runs:
|
|
1578
|
+
return None
|
|
1579
|
+
return sum((run.usage.cost_usd for run in runs if run.usage.cost_usd is not None), 0.0)
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _escalation_question(parsed: EscalationPacket) -> str | None:
|
|
1583
|
+
"""Prefer the agent's question, as the dashboard does; restored packets hold the headline."""
|
|
1584
|
+
request = parsed.agent_request
|
|
1585
|
+
if request is not None and request.question:
|
|
1586
|
+
return request.question
|
|
1587
|
+
return parsed.question
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def _escalation_resolutions(
|
|
1591
|
+
parsed: EscalationPacket, escalation: Escalation
|
|
1592
|
+
) -> list[EscalationResolutionView]:
|
|
1593
|
+
recommended = {option.label for option in escalation.options if option.recommended}
|
|
1594
|
+
return [
|
|
1595
|
+
EscalationResolutionView(
|
|
1596
|
+
kind=resolution.kind,
|
|
1597
|
+
consequences=resolution.consequences,
|
|
1598
|
+
recommended=resolution.kind in recommended,
|
|
1599
|
+
)
|
|
1600
|
+
for resolution in parsed.resolutions
|
|
1601
|
+
]
|