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,219 @@
|
|
|
1
|
+
"""Quota wait wording: the block's clock text, the provider reset and the engine retry note."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from operator import itemgetter
|
|
9
|
+
from typing import TYPE_CHECKING, Protocol
|
|
10
|
+
|
|
11
|
+
from shikiru.agents.contracts import (
|
|
12
|
+
QUOTA_WINDOW_FIVE_HOUR,
|
|
13
|
+
QUOTA_WINDOW_SEVEN_DAY,
|
|
14
|
+
FailureKind,
|
|
15
|
+
is_exhausted,
|
|
16
|
+
)
|
|
17
|
+
from shikiru.domain.events import EventKind
|
|
18
|
+
from shikiru.domain.recovery import quota_block_until
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Iterable, Sequence
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
|
|
24
|
+
from shikiru.agents.contracts import QuotaWindow
|
|
25
|
+
from shikiru.domain.events import Event
|
|
26
|
+
from shikiru.domain.tasks import Role, Task
|
|
27
|
+
|
|
28
|
+
QUOTA_BLOCKED_PHRASE = "quota blocked until"
|
|
29
|
+
QUOTA_RETRY_PHRASE = "retry at"
|
|
30
|
+
QUOTA_WORD = "quota"
|
|
31
|
+
ENGINE_RETRY_DIFFERS = "Engine retry differs from the {window} reset."
|
|
32
|
+
QUOTA_TODAY_WORD = "Today"
|
|
33
|
+
QUOTA_NOT_REPORTED = "not reported"
|
|
34
|
+
QUOTA_WINDOW_LABELS = {QUOTA_WINDOW_FIVE_HOUR: "5-hour", QUOTA_WINDOW_SEVEN_DAY: "weekly"}
|
|
35
|
+
QUOTA_WAIT_CLOCK_FORMAT = "%H:%M"
|
|
36
|
+
QUOTA_WAIT_DATE_FORMAT = "%b %d"
|
|
37
|
+
QUOTA_WAIT_ZERO_OFFSET_SUFFIX = "Z"
|
|
38
|
+
QUOTA_UNDER_A_MINUTE = "<1m"
|
|
39
|
+
QUOTA_BANNER_SEPARATOR = " · "
|
|
40
|
+
QUOTA_WAITING_SUFFIX = "waiting"
|
|
41
|
+
QUOTA_TASK_NOUN = "task"
|
|
42
|
+
QUOTA_TASKS_NOUN = "tasks"
|
|
43
|
+
QUOTA_TURN_LIFECYCLE = (
|
|
44
|
+
EventKind.STEP_STARTED,
|
|
45
|
+
EventKind.TURN_STARTED,
|
|
46
|
+
EventKind.TURN_COMPLETED,
|
|
47
|
+
EventKind.TURN_FAILED,
|
|
48
|
+
EventKind.TURN_STOPPED,
|
|
49
|
+
EventKind.RUNG_CHANGED,
|
|
50
|
+
)
|
|
51
|
+
_SECONDS_PER_MINUTE = 60
|
|
52
|
+
_MINUTES_PER_HOUR = 60
|
|
53
|
+
_HOURS_PER_DAY = 24
|
|
54
|
+
_MINUTES_PER_DAY = _HOURS_PER_DAY * _MINUTES_PER_HOUR
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class QuotaAction(StrEnum):
|
|
58
|
+
"""What the engine does at the time a quota wait shows: probe for newer evidence or retry."""
|
|
59
|
+
|
|
60
|
+
NEXT_CHECK = "next check"
|
|
61
|
+
RETRY = "retry"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ScheduledQuotaAction:
|
|
66
|
+
"""The engine's next action on the quota block set at `blocked_at`, and when it happens."""
|
|
67
|
+
|
|
68
|
+
action: QuotaAction
|
|
69
|
+
at: datetime
|
|
70
|
+
blocked_at: datetime
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def quota_reset_clock(until: datetime, now: datetime) -> str:
|
|
74
|
+
"""Return when a block lifts in the zone `until` and `now` carry, as the viewer's local time.
|
|
75
|
+
|
|
76
|
+
The date is shown when the reset falls on another local day than `now`; `Z` marks only a
|
|
77
|
+
zero offset, so a non-UTC local time never carries it.
|
|
78
|
+
"""
|
|
79
|
+
clock = until.strftime(QUOTA_WAIT_CLOCK_FORMAT)
|
|
80
|
+
if until.date() != now.date():
|
|
81
|
+
clock = f"{until.strftime(QUOTA_WAIT_DATE_FORMAT)} {clock}"
|
|
82
|
+
suffix = QUOTA_WAIT_ZERO_OFFSET_SUFFIX if until.utcoffset() == timedelta(0) else ""
|
|
83
|
+
return f"{clock}{suffix}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def provider_reset_text(resets_at: datetime, now: datetime) -> str:
|
|
87
|
+
"""Return a provider's reset as `Today` and a clock on today's local day, else as a clock."""
|
|
88
|
+
clock = quota_reset_clock(resets_at, now)
|
|
89
|
+
if resets_at.date() == now.date():
|
|
90
|
+
return f"{QUOTA_TODAY_WORD} {clock}"
|
|
91
|
+
return clock
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def quota_window_label(name: str) -> str:
|
|
95
|
+
"""Return the words a quota window is named by, such as `5-hour` for `5h`."""
|
|
96
|
+
return QUOTA_WINDOW_LABELS.get(name, name)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def quota_wait_description(agent: str, until: datetime, now: datetime) -> str:
|
|
100
|
+
"""Name the blocked `agent` and when its block lifts, in the zone `until` and `now` carry."""
|
|
101
|
+
return f"{agent} {QUOTA_BLOCKED_PHRASE} {quota_reset_clock(until, now)}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def quota_banner(description: str, waiting_tasks: int) -> str:
|
|
105
|
+
"""Append how many tasks wait behind the block to its `description`, when any do."""
|
|
106
|
+
if waiting_tasks == 0:
|
|
107
|
+
return description
|
|
108
|
+
noun = QUOTA_TASK_NOUN if waiting_tasks == 1 else QUOTA_TASKS_NOUN
|
|
109
|
+
return f"{description}{QUOTA_BANNER_SEPARATOR}{waiting_tasks} {noun} {QUOTA_WAITING_SUFFIX}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def quota_remaining(until: datetime, now: datetime) -> str:
|
|
113
|
+
"""Return the whole minutes left until a block lifts, as `1d 2h 3m`, `2h 3m` or `3m`."""
|
|
114
|
+
minutes = max(int((until - now).total_seconds()), 0) // _SECONDS_PER_MINUTE
|
|
115
|
+
if minutes == 0:
|
|
116
|
+
return QUOTA_UNDER_A_MINUTE
|
|
117
|
+
days, minutes = divmod(minutes, _MINUTES_PER_DAY)
|
|
118
|
+
hours, minutes = divmod(minutes, _MINUTES_PER_HOUR)
|
|
119
|
+
if days:
|
|
120
|
+
return f"{days}d {hours}h {minutes}m"
|
|
121
|
+
if hours:
|
|
122
|
+
return f"{hours}h {minutes}m"
|
|
123
|
+
return f"{minutes}m"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def quota_retry_description(agent: str, until: datetime, now: datetime) -> str:
|
|
127
|
+
"""Name the `agent` quota and the engine's retry, in the zone `until` carries."""
|
|
128
|
+
return (
|
|
129
|
+
f"{agent} {QUOTA_WORD}{QUOTA_BANNER_SEPARATOR}{QUOTA_RETRY_PHRASE} "
|
|
130
|
+
f"{quota_reset_clock(until, now)}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def quota_clock_text(agent: str, until: datetime, now: datetime) -> tuple[str, str]:
|
|
135
|
+
"""Return the clock-driven wording of a quota wait: its retry description and its time left."""
|
|
136
|
+
return quota_retry_description(agent, until, now), quota_remaining(until, now)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def engine_retry_note(retry: datetime, windows: Sequence[QuotaWindow], now: datetime) -> str | None:
|
|
140
|
+
"""Explain how the engine's retry differs from the provider reset it follows, when it does.
|
|
141
|
+
|
|
142
|
+
The engine follows the latest pending reset of the windows reported exhausted, plus its margin.
|
|
143
|
+
A retry on that instant needs no note; any other retry is named against that reset. Without a
|
|
144
|
+
pending exhausted reset, no reset is known to compare with, so nothing is said.
|
|
145
|
+
"""
|
|
146
|
+
pending = [
|
|
147
|
+
(window.resets_at, window.name)
|
|
148
|
+
for window in windows
|
|
149
|
+
if is_exhausted(window) and window.resets_at is not None and window.resets_at > now
|
|
150
|
+
]
|
|
151
|
+
if not pending:
|
|
152
|
+
return None
|
|
153
|
+
latest_reset, window_name = max(pending, key=itemgetter(0))
|
|
154
|
+
if retry == quota_block_until(latest_reset, now):
|
|
155
|
+
return None
|
|
156
|
+
return ENGINE_RETRY_DIFFERS.format(window=quota_window_label(window_name))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def next_quota_reload(now: datetime, instants: Iterable[datetime]) -> datetime:
|
|
160
|
+
"""Return the first of `instants` after `now`, the next moment a quota wait's data changes.
|
|
161
|
+
|
|
162
|
+
`instants` are the moments the block lifts, its reroute window closes and other blocks lift.
|
|
163
|
+
The wording moves with the clock on its own and never needs this reload.
|
|
164
|
+
"""
|
|
165
|
+
return min(instant for instant in instants if instant > now)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class LatestEvents(Protocol):
|
|
169
|
+
"""Reads the newest events of one task, in chronological order."""
|
|
170
|
+
|
|
171
|
+
def __call__(
|
|
172
|
+
self, limit: int, *, kinds: Sequence[EventKind] | None = None, task_id: str | None = None
|
|
173
|
+
) -> list[Event]:
|
|
174
|
+
"""Return up to `limit` of the newest events matching `kinds` and `task_id`."""
|
|
175
|
+
...
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def current_quota_wait_events(
|
|
179
|
+
task: Task, role: Role, agent: str, latest_events: LatestEvents
|
|
180
|
+
) -> tuple[Event, Event] | None:
|
|
181
|
+
"""Return the turn failure and quota block that put `task`'s current turn in a quota wait.
|
|
182
|
+
|
|
183
|
+
The wait is current only while the task's latest turn lifecycle event is a quota failure of
|
|
184
|
+
its current step, the turn that failed was `role`'s on `agent` in the current iteration, and
|
|
185
|
+
the latest quota event of the task is the block of that agent set after the failure.
|
|
186
|
+
"""
|
|
187
|
+
if task.step is None:
|
|
188
|
+
return None
|
|
189
|
+
lifecycle = latest_events(1, kinds=QUOTA_TURN_LIFECYCLE, task_id=task.id)
|
|
190
|
+
if (
|
|
191
|
+
not lifecycle
|
|
192
|
+
or lifecycle[0].kind is not EventKind.TURN_FAILED
|
|
193
|
+
or lifecycle[0].data.get("kind") != FailureKind.QUOTA.value
|
|
194
|
+
or lifecycle[0].data.get("step") != task.step.value
|
|
195
|
+
):
|
|
196
|
+
return None
|
|
197
|
+
failure = lifecycle[0]
|
|
198
|
+
starts = latest_events(1, kinds=(EventKind.TURN_STARTED,), task_id=task.id)
|
|
199
|
+
if not starts or starts[0].id >= failure.id:
|
|
200
|
+
return None
|
|
201
|
+
started = starts[0]
|
|
202
|
+
if (
|
|
203
|
+
started.data.get("step") != task.step.value
|
|
204
|
+
or started.data.get("iteration") != task.iteration
|
|
205
|
+
or started.data.get("role") != role.value
|
|
206
|
+
or started.data.get("agent") != agent
|
|
207
|
+
):
|
|
208
|
+
return None
|
|
209
|
+
quota_events = latest_events(
|
|
210
|
+
1, kinds=(EventKind.QUOTA_BLOCKED, EventKind.QUOTA_CLEARED), task_id=task.id
|
|
211
|
+
)
|
|
212
|
+
if (
|
|
213
|
+
not quota_events
|
|
214
|
+
or quota_events[0].kind is not EventKind.QUOTA_BLOCKED
|
|
215
|
+
or quota_events[0].id <= failure.id
|
|
216
|
+
or quota_events[0].data.get("agent") != agent
|
|
217
|
+
):
|
|
218
|
+
return None
|
|
219
|
+
return failure, quota_events[0]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Recovery decisions for every non-completed turn result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shikiru.agents.contracts import FailureKind, exhausted_reset
|
|
10
|
+
from shikiru.domain.tasks import EscalationReason
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
from shikiru.agents.contracts import QuotaWindow
|
|
16
|
+
from shikiru.domain.reports import Report
|
|
17
|
+
|
|
18
|
+
MAX_STALL_RECOVERIES_PER_TURN = 2
|
|
19
|
+
OTHER_RETRIES_PER_TURN = 1
|
|
20
|
+
QUOTA_MARGIN = timedelta(seconds=60)
|
|
21
|
+
QUOTA_PROBE = timedelta(minutes=15)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Completed:
|
|
26
|
+
"""A turn that produced a valid report, possibly rebuilt by the Helper."""
|
|
27
|
+
|
|
28
|
+
report: Report
|
|
29
|
+
rebuilt: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Failed:
|
|
34
|
+
"""A turn the agent reported as failed, with its normalized failure kind."""
|
|
35
|
+
|
|
36
|
+
kind: FailureKind
|
|
37
|
+
resets_at: datetime | None
|
|
38
|
+
message: str
|
|
39
|
+
unclassified: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Stalled:
|
|
44
|
+
"""No stream signal arrived within the configured timeout."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ProtocolError:
|
|
49
|
+
"""The turn ended without a report even after nudges and a Helper rebuild attempt."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
TurnResult = Completed | Failed | Stalled | ProtocolError
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class TurnCounters:
|
|
57
|
+
"""In-memory counters for the current turn only; a restart resets them."""
|
|
58
|
+
|
|
59
|
+
stall_recoveries: int = 0
|
|
60
|
+
unavailable_retries: int = 0
|
|
61
|
+
other_retries: int = 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class RecoveryPolicy:
|
|
66
|
+
"""What `decide` needs beyond the result and counters: time and ladder availability."""
|
|
67
|
+
|
|
68
|
+
now: datetime
|
|
69
|
+
max_quota_wait: timedelta
|
|
70
|
+
has_next_rung: bool
|
|
71
|
+
has_next_rung_different_agent: bool
|
|
72
|
+
unavailable_initial_seconds: float
|
|
73
|
+
unavailable_multiplier: float
|
|
74
|
+
unavailable_max_seconds: float
|
|
75
|
+
unavailable_max_retries: int
|
|
76
|
+
quota_wait_exhausted: bool = False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class WaitForQuota:
|
|
81
|
+
"""Block the agent until `until`, then resume the step on the same session."""
|
|
82
|
+
|
|
83
|
+
until: datetime
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Backoff:
|
|
88
|
+
"""Retry the same rung after `delay`."""
|
|
89
|
+
|
|
90
|
+
delay: timedelta
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class NextRung:
|
|
95
|
+
"""Move to the next rung, optionally requiring a different agent."""
|
|
96
|
+
|
|
97
|
+
different_agent: bool
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class ResumeTurn:
|
|
102
|
+
"""Resume the same agent session with the continue prompt."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class RetryTurn:
|
|
107
|
+
"""Re-run the current turn on the live session."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Escalate:
|
|
112
|
+
"""Raise an escalation with `reason`."""
|
|
113
|
+
|
|
114
|
+
reason: EscalationReason
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
Action = WaitForQuota | Backoff | NextRung | ResumeTurn | RetryTurn | Escalate
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def quota_block_until(resets_at: datetime | None, now: datetime) -> datetime:
|
|
121
|
+
"""Return the agent-block deadline for a quota failure."""
|
|
122
|
+
return (resets_at + QUOTA_MARGIN) if resets_at is not None else (now + QUOTA_PROBE)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def with_known_quota_reset(
|
|
126
|
+
result: TurnResult, windows: Sequence[QuotaWindow], now: datetime
|
|
127
|
+
) -> TurnResult:
|
|
128
|
+
"""Fill a quota failure's missing reset from the exhausted `windows`, when they name one."""
|
|
129
|
+
if (
|
|
130
|
+
not isinstance(result, Failed)
|
|
131
|
+
or result.kind is not FailureKind.QUOTA
|
|
132
|
+
or result.resets_at is not None
|
|
133
|
+
):
|
|
134
|
+
return result
|
|
135
|
+
resets_at = exhausted_reset(windows, now)
|
|
136
|
+
return result if resets_at is None else replace(result, resets_at=resets_at)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _quota_action(resets_at: datetime | None, policy: RecoveryPolicy) -> Action:
|
|
140
|
+
if policy.quota_wait_exhausted:
|
|
141
|
+
if policy.has_next_rung_different_agent:
|
|
142
|
+
return NextRung(different_agent=True)
|
|
143
|
+
return Escalate(EscalationReason.QUOTA_EXHAUSTED)
|
|
144
|
+
until = quota_block_until(resets_at, policy.now)
|
|
145
|
+
if until - policy.now > policy.max_quota_wait:
|
|
146
|
+
if policy.has_next_rung_different_agent:
|
|
147
|
+
return NextRung(different_agent=True)
|
|
148
|
+
return Escalate(EscalationReason.QUOTA_EXHAUSTED)
|
|
149
|
+
return WaitForQuota(until=until)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _decide_unavailable(counters: TurnCounters, policy: RecoveryPolicy) -> Action:
|
|
153
|
+
if counters.unavailable_retries < policy.unavailable_max_retries:
|
|
154
|
+
delay = policy.unavailable_initial_seconds
|
|
155
|
+
for _ in range(counters.unavailable_retries):
|
|
156
|
+
if delay >= policy.unavailable_max_seconds / policy.unavailable_multiplier:
|
|
157
|
+
delay = policy.unavailable_max_seconds
|
|
158
|
+
break
|
|
159
|
+
delay *= policy.unavailable_multiplier
|
|
160
|
+
return Backoff(delay=timedelta(seconds=min(delay, policy.unavailable_max_seconds)))
|
|
161
|
+
if policy.has_next_rung:
|
|
162
|
+
return NextRung(different_agent=False)
|
|
163
|
+
return Escalate(EscalationReason.LADDER_EXHAUSTED)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _decide_failure(result: Failed, counters: TurnCounters, policy: RecoveryPolicy) -> Action:
|
|
167
|
+
action: Action
|
|
168
|
+
if result.unclassified:
|
|
169
|
+
action = Escalate(EscalationReason.AGENT_ERROR)
|
|
170
|
+
elif result.kind is FailureKind.QUOTA:
|
|
171
|
+
action = _quota_action(result.resets_at, policy)
|
|
172
|
+
elif result.kind is FailureKind.UNAVAILABLE:
|
|
173
|
+
action = _decide_unavailable(counters, policy)
|
|
174
|
+
elif result.kind is FailureKind.AUTH:
|
|
175
|
+
action = Escalate(EscalationReason.AGENT_AUTH)
|
|
176
|
+
elif result.kind is FailureKind.OTHER and counters.other_retries < OTHER_RETRIES_PER_TURN:
|
|
177
|
+
action = RetryTurn()
|
|
178
|
+
else:
|
|
179
|
+
action = Escalate(EscalationReason.AGENT_ERROR)
|
|
180
|
+
return action
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def decide(result: TurnResult, counters: TurnCounters, policy: RecoveryPolicy) -> Action:
|
|
184
|
+
"""Map a non-`Completed` turn result to the action recovery should take."""
|
|
185
|
+
if isinstance(result, Failed):
|
|
186
|
+
return _decide_failure(result, counters, policy)
|
|
187
|
+
if isinstance(result, Stalled):
|
|
188
|
+
if counters.stall_recoveries < MAX_STALL_RECOVERIES_PER_TURN:
|
|
189
|
+
return ResumeTurn()
|
|
190
|
+
return Escalate(EscalationReason.AGENT_STUCK)
|
|
191
|
+
if isinstance(result, ProtocolError):
|
|
192
|
+
return Escalate(EscalationReason.AGENT_PROTOCOL)
|
|
193
|
+
message = "recovery.decide is only for non-Completed turn results"
|
|
194
|
+
raise ValueError(message)
|