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,117 @@
|
|
|
1
|
+
"""Off-loop, retried closing of the pull requests of cancelled tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shikiru.domain.faults import next_backoff
|
|
10
|
+
from shikiru.domain.tasks import Status
|
|
11
|
+
from shikiru.engine.fault_reporter import RECOVERABLE_FAULTS
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from logging import Logger
|
|
15
|
+
|
|
16
|
+
from shikiru.clock import Clock
|
|
17
|
+
from shikiru.config.settings import OrchestrationConfig
|
|
18
|
+
from shikiru.engine.fault_reporter import FaultReporter
|
|
19
|
+
from shikiru.engine.integration import PullRequestCloser
|
|
20
|
+
from shikiru.store.tasks import TaskStore
|
|
21
|
+
|
|
22
|
+
CLOSE_FAULT_SCOPE = "pull request close"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PullRequestClosing:
|
|
26
|
+
"""Closes cancelled tasks' open pull requests on its own task, never on the command loop.
|
|
27
|
+
|
|
28
|
+
A failed close is recorded through the `FaultReporter` and retried on a doubling backoff for
|
|
29
|
+
`network.pr_close_attempts` tries; after that it waits for the next engine start, whose
|
|
30
|
+
`restore` queues every cancelled task whose pull request is still open.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
closer: PullRequestCloser,
|
|
37
|
+
tasks: TaskStore,
|
|
38
|
+
faults: FaultReporter,
|
|
39
|
+
clock: Clock,
|
|
40
|
+
settings: OrchestrationConfig,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._closer = closer
|
|
43
|
+
self._tasks = tasks
|
|
44
|
+
self._faults = faults
|
|
45
|
+
self._clock = clock
|
|
46
|
+
self._settings = settings
|
|
47
|
+
self._queue: dict[str, None] = {}
|
|
48
|
+
self._pending = asyncio.Event()
|
|
49
|
+
self._task: asyncio.Task[None] | None = None
|
|
50
|
+
|
|
51
|
+
def start(self) -> None:
|
|
52
|
+
"""Start the closing task."""
|
|
53
|
+
self._task = asyncio.create_task(self._run())
|
|
54
|
+
|
|
55
|
+
def request(self, task_id: str) -> None:
|
|
56
|
+
"""Queue closing the open pull request of cancelled `task_id`, if it has one."""
|
|
57
|
+
self._queue[task_id] = None
|
|
58
|
+
self._pending.set()
|
|
59
|
+
|
|
60
|
+
def has_open(self, task_id: str) -> bool:
|
|
61
|
+
"""Return whether `task_id` has a pull request that was neither closed nor withdrawn."""
|
|
62
|
+
return self._closer.has_open(task_id)
|
|
63
|
+
|
|
64
|
+
def restore(self) -> None:
|
|
65
|
+
"""Queue every cancelled task whose pull request is still open."""
|
|
66
|
+
for task in self._tasks.list_all():
|
|
67
|
+
if task.status is Status.CANCELLED and self.has_open(task.id):
|
|
68
|
+
self.request(task.id)
|
|
69
|
+
|
|
70
|
+
def supervise(self, logger: Logger) -> None:
|
|
71
|
+
"""Log a closing task failure and start a replacement task."""
|
|
72
|
+
task = self._task
|
|
73
|
+
if task is None or not task.done():
|
|
74
|
+
return
|
|
75
|
+
if not task.cancelled() and (error := task.exception()) is not None:
|
|
76
|
+
logger.error(
|
|
77
|
+
"pull request closing failed", exc_info=(type(error), error, error.__traceback__)
|
|
78
|
+
)
|
|
79
|
+
self.start()
|
|
80
|
+
|
|
81
|
+
async def shutdown(self) -> None:
|
|
82
|
+
"""Cancel the closing task; queued closes are repeated by the next engine start."""
|
|
83
|
+
task, self._task = self._task, None
|
|
84
|
+
if task is None:
|
|
85
|
+
return
|
|
86
|
+
task.cancel()
|
|
87
|
+
with suppress(asyncio.CancelledError):
|
|
88
|
+
await task
|
|
89
|
+
|
|
90
|
+
async def _run(self) -> None:
|
|
91
|
+
while True:
|
|
92
|
+
if not self._queue:
|
|
93
|
+
self._pending.clear()
|
|
94
|
+
await self._pending.wait()
|
|
95
|
+
continue
|
|
96
|
+
task_id = next(iter(self._queue))
|
|
97
|
+
del self._queue[task_id]
|
|
98
|
+
await self._close(task_id)
|
|
99
|
+
|
|
100
|
+
async def _close(self, task_id: str) -> None:
|
|
101
|
+
faults = self._settings.faults
|
|
102
|
+
delay = 0.0
|
|
103
|
+
for attempt in range(1, self._settings.network.pr_close_attempts + 1):
|
|
104
|
+
try:
|
|
105
|
+
await self._closer.close_open(task_id)
|
|
106
|
+
except RECOVERABLE_FAULTS as error:
|
|
107
|
+
self._faults.report(error, scope=CLOSE_FAULT_SCOPE, task_id=task_id)
|
|
108
|
+
delay = next_backoff(
|
|
109
|
+
delay,
|
|
110
|
+
initial=faults.backoff_initial_seconds,
|
|
111
|
+
maximum=faults.backoff_max_seconds,
|
|
112
|
+
)
|
|
113
|
+
if attempt < self._settings.network.pr_close_attempts:
|
|
114
|
+
await self._clock.sleep(delay)
|
|
115
|
+
else:
|
|
116
|
+
self._faults.resolve(CLOSE_FAULT_SCOPE, task_id=task_id)
|
|
117
|
+
return
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Off-loop publication of locally integrated base branches to the remote."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.domain.pushes import is_due
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from logging import Logger
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from shikiru.clock import Clock
|
|
19
|
+
from shikiru.domain.pushes import PushTarget
|
|
20
|
+
from shikiru.engine.tick_guard import TickGuard
|
|
21
|
+
from shikiru.store.pushes import PushTargets
|
|
22
|
+
from shikiru.workspace.git import Git
|
|
23
|
+
|
|
24
|
+
PUSH_FAULT_SCOPE = "base push"
|
|
25
|
+
REMOTE_CHECK_FAULT_SCOPE = "remote check"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PushService:
|
|
29
|
+
"""Registers integrated bases and pushes each when its deadline comes, on its own task.
|
|
30
|
+
|
|
31
|
+
Each base's reconciliation and push use its own `TickGuard`, while remote configuration uses
|
|
32
|
+
`remote_guard`. A failed base check, rejected push or timeout is recorded and retried on fault
|
|
33
|
+
backoff without holding back other bases. While work is pending, the service refreshes remote
|
|
34
|
+
refs every `remote_recheck_seconds`; matching local and remote SHAs clear only the registrations
|
|
35
|
+
observed by that check. A remote that is not configured is not a fault: work stays pending and
|
|
36
|
+
the remote is checked again after `remote_recheck_seconds`. A push acknowledges only the
|
|
37
|
+
integrations that were registered when it started.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
targets: PushTargets,
|
|
44
|
+
git: Git,
|
|
45
|
+
guard_for: Callable[[str], TickGuard],
|
|
46
|
+
remote_guard: TickGuard,
|
|
47
|
+
clock: Clock,
|
|
48
|
+
project_root: Path,
|
|
49
|
+
remote: str,
|
|
50
|
+
interval_seconds: int,
|
|
51
|
+
remote_recheck_seconds: int,
|
|
52
|
+
fetch_timeout_seconds: float,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Push from `project_root` to `remote`, an interval of `interval_seconds` apart."""
|
|
55
|
+
self._targets = targets
|
|
56
|
+
self._git = git
|
|
57
|
+
self._guard_for = guard_for
|
|
58
|
+
self._guards: dict[str, TickGuard] = {}
|
|
59
|
+
self._remote_guard = remote_guard
|
|
60
|
+
self._clock = clock
|
|
61
|
+
self._project_root = project_root
|
|
62
|
+
self._remote = remote
|
|
63
|
+
self._interval = interval_seconds
|
|
64
|
+
self._remote_recheck = timedelta(seconds=remote_recheck_seconds)
|
|
65
|
+
self._fetch_timeout = fetch_timeout_seconds
|
|
66
|
+
self._task: asyncio.Task[None] | None = None
|
|
67
|
+
self._next_remote_check: datetime | None = None
|
|
68
|
+
self._remote_available: bool | None = None
|
|
69
|
+
|
|
70
|
+
def register(self, base: str) -> None:
|
|
71
|
+
"""Record an integration of `base` that must reach the remote."""
|
|
72
|
+
current = self._targets.get(base)
|
|
73
|
+
self._targets.register(base, now=self._clock.now(), interval=self._interval)
|
|
74
|
+
if current is None or not current.pending:
|
|
75
|
+
self._remote_available = None
|
|
76
|
+
self._next_remote_check = None
|
|
77
|
+
|
|
78
|
+
def poll(self, logger: Logger) -> None:
|
|
79
|
+
"""Log a failed push task and start one when work is due and none is running."""
|
|
80
|
+
task = self._task
|
|
81
|
+
if task is not None:
|
|
82
|
+
if not task.done():
|
|
83
|
+
return
|
|
84
|
+
self._task = None
|
|
85
|
+
if not task.cancelled() and (error := task.exception()) is not None:
|
|
86
|
+
logger.error("base push failed", exc_info=(type(error), error, error.__traceback__))
|
|
87
|
+
if self._due():
|
|
88
|
+
self._task = asyncio.create_task(self._push_due())
|
|
89
|
+
|
|
90
|
+
async def finish_outstanding(self) -> None:
|
|
91
|
+
"""Wait for the push in flight, if there is one."""
|
|
92
|
+
task = self._task
|
|
93
|
+
if task is not None:
|
|
94
|
+
await asyncio.wait((task,))
|
|
95
|
+
|
|
96
|
+
async def shutdown(self) -> None:
|
|
97
|
+
"""Cancel an outstanding push; its pending work is sent by the next engine start."""
|
|
98
|
+
task, self._task = self._task, None
|
|
99
|
+
if task is None:
|
|
100
|
+
return
|
|
101
|
+
task.cancel()
|
|
102
|
+
with suppress(asyncio.CancelledError):
|
|
103
|
+
await task
|
|
104
|
+
|
|
105
|
+
def _due(self) -> bool:
|
|
106
|
+
now = self._clock.now()
|
|
107
|
+
pending = self._targets.pending()
|
|
108
|
+
if not pending:
|
|
109
|
+
return False
|
|
110
|
+
remote_check_due = self._next_remote_check is None or now >= self._next_remote_check
|
|
111
|
+
if remote_check_due:
|
|
112
|
+
return True
|
|
113
|
+
if self._remote_available is False:
|
|
114
|
+
return False
|
|
115
|
+
return any(is_due(target, now=now, interval=self._interval) for target in pending)
|
|
116
|
+
|
|
117
|
+
async def _push_due(self) -> None:
|
|
118
|
+
pending = self._targets.pending()
|
|
119
|
+
if not pending:
|
|
120
|
+
return
|
|
121
|
+
now = self._clock.now()
|
|
122
|
+
remote_check_due = self._next_remote_check is None or now >= self._next_remote_check
|
|
123
|
+
push_due = any(is_due(target, now=now, interval=self._interval) for target in pending)
|
|
124
|
+
if not remote_check_due and not push_due:
|
|
125
|
+
return
|
|
126
|
+
configured = await self._remote_guard.attempt(self._remote_configured)
|
|
127
|
+
if configured is None:
|
|
128
|
+
if remote_check_due:
|
|
129
|
+
self._next_remote_check = self._clock.now() + self._remote_recheck
|
|
130
|
+
return
|
|
131
|
+
self._remote_available = configured
|
|
132
|
+
if not configured:
|
|
133
|
+
self._next_remote_check = self._clock.now() + self._remote_recheck
|
|
134
|
+
return
|
|
135
|
+
if remote_check_due:
|
|
136
|
+
self._next_remote_check = self._clock.now() + self._remote_recheck
|
|
137
|
+
for target in pending:
|
|
138
|
+
await self._guard(target.base).attempt(
|
|
139
|
+
lambda target=target: self._reconcile(target)
|
|
140
|
+
)
|
|
141
|
+
for target in self._targets.pending():
|
|
142
|
+
if is_due(target, now=self._clock.now(), interval=self._interval):
|
|
143
|
+
await self._guard(target.base).attempt(lambda target=target: self._push(target))
|
|
144
|
+
|
|
145
|
+
async def _reconcile(self, target: PushTarget) -> None:
|
|
146
|
+
remote_sha = await self._git.fetch_remote_branch(
|
|
147
|
+
self._project_root,
|
|
148
|
+
self._remote,
|
|
149
|
+
target.base,
|
|
150
|
+
timeout=self._fetch_timeout,
|
|
151
|
+
)
|
|
152
|
+
local_sha = await self._git.local_branch_sha(self._project_root, target.base)
|
|
153
|
+
if remote_sha is not None and remote_sha == local_sha:
|
|
154
|
+
self._targets.reconcile(
|
|
155
|
+
target.base,
|
|
156
|
+
registered=target.registered,
|
|
157
|
+
now=self._clock.now(),
|
|
158
|
+
interval=self._interval,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def _remote_configured(self) -> bool:
|
|
162
|
+
return await self._git.remote_configured(self._project_root, self._remote)
|
|
163
|
+
|
|
164
|
+
def _guard(self, base: str) -> TickGuard:
|
|
165
|
+
if base not in self._guards:
|
|
166
|
+
self._guards[base] = self._guard_for(base)
|
|
167
|
+
return self._guards[base]
|
|
168
|
+
|
|
169
|
+
async def _push(self, target: PushTarget) -> bool:
|
|
170
|
+
await self._git.push_base(self._project_root, self._remote, target.base)
|
|
171
|
+
self._targets.acknowledge(
|
|
172
|
+
target.base,
|
|
173
|
+
registered=target.registered,
|
|
174
|
+
now=self._clock.now(),
|
|
175
|
+
interval=self._interval,
|
|
176
|
+
)
|
|
177
|
+
return True
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Owned threads for blocking quota reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shikiru.errors import ShikiruError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from shikiru.agents.contracts import QuotaReading
|
|
16
|
+
|
|
17
|
+
THREAD_NAME_PREFIX = "quota-probe-"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _deliver(
|
|
21
|
+
future: asyncio.Future[QuotaReading | None],
|
|
22
|
+
reading: QuotaReading | None,
|
|
23
|
+
error: Exception | None,
|
|
24
|
+
) -> None:
|
|
25
|
+
if future.done():
|
|
26
|
+
return
|
|
27
|
+
if error is not None:
|
|
28
|
+
future.set_exception(error)
|
|
29
|
+
else:
|
|
30
|
+
future.set_result(reading)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class QuotaProbeThreads:
|
|
34
|
+
"""Runs blocking quota reads, each agent's on at most one daemon thread at a time.
|
|
35
|
+
|
|
36
|
+
A blocked read cannot be interrupted, so it is never abandoned and replaced: while an
|
|
37
|
+
agent's thread has not returned, a new read of that agent is refused. The threads are
|
|
38
|
+
daemons, so a read that never returns delays neither shutdown nor process exit.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
"""Start with no thread running."""
|
|
43
|
+
self._threads: dict[str, threading.Thread] = {}
|
|
44
|
+
|
|
45
|
+
def busy(self, agent: str) -> bool:
|
|
46
|
+
"""Return whether `agent`'s previous read is still running."""
|
|
47
|
+
thread = self._threads.get(agent)
|
|
48
|
+
return thread is not None and thread.is_alive()
|
|
49
|
+
|
|
50
|
+
def start(
|
|
51
|
+
self,
|
|
52
|
+
agent: str,
|
|
53
|
+
read: Callable[[datetime], QuotaReading | None],
|
|
54
|
+
since: datetime,
|
|
55
|
+
) -> asyncio.Future[QuotaReading | None]:
|
|
56
|
+
"""Run `read(since)` on a new thread and return a future for its outcome.
|
|
57
|
+
|
|
58
|
+
Raises `ShikiruError` while `agent`'s previous read is still running.
|
|
59
|
+
"""
|
|
60
|
+
if self.busy(agent):
|
|
61
|
+
message = f"the previous quota probe of {agent} is still waiting for its source"
|
|
62
|
+
raise ShikiruError(message)
|
|
63
|
+
loop = asyncio.get_running_loop()
|
|
64
|
+
future: asyncio.Future[QuotaReading | None] = loop.create_future()
|
|
65
|
+
|
|
66
|
+
def run() -> None:
|
|
67
|
+
reading: QuotaReading | None = None
|
|
68
|
+
failure: Exception | None = None
|
|
69
|
+
try:
|
|
70
|
+
reading = read(since)
|
|
71
|
+
except Exception as error: # noqa: BLE001
|
|
72
|
+
failure = error
|
|
73
|
+
if not loop.is_closed():
|
|
74
|
+
loop.call_soon_threadsafe(_deliver, future, reading, failure)
|
|
75
|
+
|
|
76
|
+
thread = threading.Thread(target=run, name=f"{THREAD_NAME_PREFIX}{agent}", daemon=True)
|
|
77
|
+
self._threads[agent] = thread
|
|
78
|
+
thread.start()
|
|
79
|
+
return future
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Revalidates persisted quota blocks against fresh evidence from the agent plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.agents.contracts import quota_probe_for
|
|
11
|
+
from shikiru.domain.events import EventKind
|
|
12
|
+
from shikiru.domain.quota_blocks import block_released
|
|
13
|
+
from shikiru.domain.quota_waits import QuotaAction, ScheduledQuotaAction
|
|
14
|
+
from shikiru.engine.quota_probe_threads import QuotaProbeThreads
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
from shikiru.agents.contracts import QuotaProbe, QuotaReading, QuotaWindow
|
|
21
|
+
from shikiru.clock import Clock
|
|
22
|
+
from shikiru.config.settings import QuotaRefreshConfig
|
|
23
|
+
from shikiru.engine.agents import AgentPool
|
|
24
|
+
from shikiru.engine.fault_reporter import FaultReporter
|
|
25
|
+
from shikiru.engine.quota_schedule import QuotaSchedule
|
|
26
|
+
from shikiru.store.events import EventLog
|
|
27
|
+
from shikiru.store.quotas import AgentBlock, QuotaStore
|
|
28
|
+
|
|
29
|
+
QUOTA_REFRESH_SCOPE = "quota refresh"
|
|
30
|
+
QUOTA_RELEASE_REASON = "newer quota reading shows available quota"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class _Probe:
|
|
35
|
+
blocked_at: datetime
|
|
36
|
+
task: asyncio.Task[None]
|
|
37
|
+
demanded: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class QuotaReconciler:
|
|
41
|
+
"""Releases a quota block when a newer applicable reading shows its quota is available.
|
|
42
|
+
|
|
43
|
+
A reading counts only when it was observed after the block and covers every window the
|
|
44
|
+
block was exhausted on. Out-of-turn probes run beside the caller on a bounded interval, one
|
|
45
|
+
per block at a time and each for a bounded time; an explicit requeue forces one, and a probe
|
|
46
|
+
that fails or times out is recorded as an engine fault. The result of a probe whose block
|
|
47
|
+
has since changed is dropped.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
quotas: QuotaStore,
|
|
54
|
+
agents: AgentPool,
|
|
55
|
+
events: EventLog,
|
|
56
|
+
clock: Clock,
|
|
57
|
+
settings: QuotaRefreshConfig,
|
|
58
|
+
faults: FaultReporter,
|
|
59
|
+
schedule: QuotaSchedule,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Probe through `agents`, persist in `quotas`, record outcomes in `events`.
|
|
62
|
+
|
|
63
|
+
The next examination or retry of each block is recorded in `schedule`.
|
|
64
|
+
"""
|
|
65
|
+
self._quotas = quotas
|
|
66
|
+
self._agents = agents
|
|
67
|
+
self._events = events
|
|
68
|
+
self._clock = clock
|
|
69
|
+
self._settings = settings
|
|
70
|
+
self._faults = faults
|
|
71
|
+
self._probed_at: dict[str, datetime] = {}
|
|
72
|
+
self._probes: dict[str, _Probe] = {}
|
|
73
|
+
self._threads = QuotaProbeThreads()
|
|
74
|
+
self._schedule = schedule
|
|
75
|
+
|
|
76
|
+
def observe(self, agent: str, windows: Sequence[QuotaWindow]) -> None:
|
|
77
|
+
"""Record `windows` for `agent`, first releasing its block when they prove it obsolete."""
|
|
78
|
+
now = self._clock.now()
|
|
79
|
+
block = self._quotas.block_for(agent)
|
|
80
|
+
if (
|
|
81
|
+
block is not None
|
|
82
|
+
and block.until > now
|
|
83
|
+
and block_released(
|
|
84
|
+
blocked_at=block.blocked_at,
|
|
85
|
+
stored=self._quotas.windows_for(agent),
|
|
86
|
+
reading=windows,
|
|
87
|
+
now=now,
|
|
88
|
+
)
|
|
89
|
+
):
|
|
90
|
+
self._quotas.clear_block(agent)
|
|
91
|
+
self._events.append(
|
|
92
|
+
EventKind.QUOTA_CLEARED,
|
|
93
|
+
message=f"{agent} quota block cleared",
|
|
94
|
+
data={"agent": agent, "reason": QUOTA_RELEASE_REASON},
|
|
95
|
+
)
|
|
96
|
+
for window in windows:
|
|
97
|
+
self._quotas.record(agent, window)
|
|
98
|
+
|
|
99
|
+
def request_refresh(
|
|
100
|
+
self, agent: str, *, demanded_at: datetime | None = None
|
|
101
|
+
) -> asyncio.Task[None] | None:
|
|
102
|
+
"""Start a supervised probe of `agent`'s plugin while its block is in effect.
|
|
103
|
+
|
|
104
|
+
Never waits for the probe. Probes at most once per interval, unless `demanded_at`, such
|
|
105
|
+
as a task requeue, is later than the last probe; a demanded probe is not delayed by the
|
|
106
|
+
interval, and a requeue that arrives during a running probe of the same block makes that
|
|
107
|
+
probe demanded. Never twice at once for the same block; a probe of a block since replaced
|
|
108
|
+
is cancelled. Returns the running probe, or `None` when nothing was started or the block is
|
|
109
|
+
not examined.
|
|
110
|
+
"""
|
|
111
|
+
now = self._clock.now()
|
|
112
|
+
block = self._quotas.block_for(agent)
|
|
113
|
+
if block is None or block.until <= now:
|
|
114
|
+
return None
|
|
115
|
+
last = self._probed_at.get(agent)
|
|
116
|
+
demanded = demanded_at is not None and (last is None or demanded_at > last)
|
|
117
|
+
running = self._probes.get(agent)
|
|
118
|
+
if running is not None and not running.task.done():
|
|
119
|
+
if running.blocked_at == block.blocked_at:
|
|
120
|
+
if demanded and not running.demanded:
|
|
121
|
+
self._probes[agent] = _Probe(running.blocked_at, running.task, demanded=True)
|
|
122
|
+
return running.task
|
|
123
|
+
running.task.cancel()
|
|
124
|
+
interval = timedelta(seconds=self._settings.interval_seconds)
|
|
125
|
+
if last is not None and now - last < interval and not demanded:
|
|
126
|
+
return None
|
|
127
|
+
self._probed_at[agent] = now
|
|
128
|
+
task = asyncio.Task(self._probe(agent, block, now + interval), eager_start=True)
|
|
129
|
+
self._probes[agent] = _Probe(block.blocked_at, task, demanded=demanded)
|
|
130
|
+
return task
|
|
131
|
+
|
|
132
|
+
async def refresh(self, agent: str, *, demanded_at: datetime | None = None) -> None:
|
|
133
|
+
"""Probe `agent`'s plugin as `request_refresh` does and wait until the probe ends."""
|
|
134
|
+
task = self.request_refresh(agent, demanded_at=demanded_at)
|
|
135
|
+
if task is not None:
|
|
136
|
+
await asyncio.wait({task})
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def probing(self) -> bool:
|
|
140
|
+
"""Return whether any probe is still running."""
|
|
141
|
+
return any(not probe.task.done() for probe in self._probes.values())
|
|
142
|
+
|
|
143
|
+
def awaiting_demanded_probe(self, block: AgentBlock) -> bool:
|
|
144
|
+
"""Return whether a requeue-demanded probe of `block` is still running."""
|
|
145
|
+
probe = self._probes.get(block.agent)
|
|
146
|
+
return (
|
|
147
|
+
probe is not None
|
|
148
|
+
and probe.demanded
|
|
149
|
+
and probe.blocked_at == block.blocked_at
|
|
150
|
+
and not probe.task.done()
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def shutdown(self) -> None:
|
|
154
|
+
"""Cancel every probe in flight and wait until each has ended."""
|
|
155
|
+
tasks = [probe.task for probe in self._probes.values()]
|
|
156
|
+
self._probes.clear()
|
|
157
|
+
for task in tasks:
|
|
158
|
+
task.cancel()
|
|
159
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
160
|
+
|
|
161
|
+
async def _probe(self, agent: str, block: AgentBlock, next_check: datetime) -> None:
|
|
162
|
+
async with self._faults.isolate(f"{QUOTA_REFRESH_SCOPE} {agent}"):
|
|
163
|
+
self.expect_check(agent, next_check)
|
|
164
|
+
probe = quota_probe_for(self._agents.get(agent))
|
|
165
|
+
if probe is None:
|
|
166
|
+
return
|
|
167
|
+
reading = await self._read(agent, probe, block.blocked_at)
|
|
168
|
+
current = self._quotas.block_for(agent)
|
|
169
|
+
if (
|
|
170
|
+
reading is not None
|
|
171
|
+
and current is not None
|
|
172
|
+
and current.blocked_at == block.blocked_at
|
|
173
|
+
):
|
|
174
|
+
self.observe(agent, reading.windows)
|
|
175
|
+
|
|
176
|
+
async def _read(
|
|
177
|
+
self, agent: str, probe: QuotaProbe, blocked_at: datetime
|
|
178
|
+
) -> QuotaReading | None:
|
|
179
|
+
timeout = self._settings.timeout_seconds
|
|
180
|
+
read = self._threads.start(agent, probe.read_quota, blocked_at)
|
|
181
|
+
timer = asyncio.create_task(self._clock.sleep(timeout))
|
|
182
|
+
try:
|
|
183
|
+
await asyncio.wait({read, timer}, return_when=asyncio.FIRST_COMPLETED)
|
|
184
|
+
if not read.done():
|
|
185
|
+
message = f"quota probe did not answer within {timeout} seconds"
|
|
186
|
+
raise TimeoutError(message)
|
|
187
|
+
return read.result()
|
|
188
|
+
finally:
|
|
189
|
+
read.cancel()
|
|
190
|
+
timer.cancel()
|
|
191
|
+
await asyncio.gather(read, timer, return_exceptions=True)
|
|
192
|
+
|
|
193
|
+
def expect_check(self, agent: str, at: datetime) -> None:
|
|
194
|
+
"""Record that `agent`'s block is next examined at `at`, or retried when it lifts first.
|
|
195
|
+
|
|
196
|
+
An agent whose plugin cannot probe is not examined: its block is retried when it lifts.
|
|
197
|
+
"""
|
|
198
|
+
block = self._quotas.block_for(agent)
|
|
199
|
+
if block is None:
|
|
200
|
+
return
|
|
201
|
+
if block.until <= at or quota_probe_for(self._agents.get(agent)) is None:
|
|
202
|
+
scheduled = ScheduledQuotaAction(QuotaAction.RETRY, block.until, block.blocked_at)
|
|
203
|
+
else:
|
|
204
|
+
scheduled = ScheduledQuotaAction(QuotaAction.NEXT_CHECK, at, block.blocked_at)
|
|
205
|
+
self._schedule.record(agent, scheduled)
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def interval_seconds(self) -> float:
|
|
209
|
+
"""Return the longest an obsolete block can go unnoticed while someone waits on it."""
|
|
210
|
+
return self._settings.interval_seconds
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""What the engine will next do about each quota-blocked agent, for the views that show it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from shikiru.domain.quota_waits import ScheduledQuotaAction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QuotaSchedule:
|
|
12
|
+
"""The next scheduled action per agent, in memory: it is rebuilt as the engine acts again."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
"""Start with no action scheduled."""
|
|
16
|
+
self._actions: dict[str, ScheduledQuotaAction] = {}
|
|
17
|
+
|
|
18
|
+
def record(self, agent: str, action: ScheduledQuotaAction) -> None:
|
|
19
|
+
"""Remember `action` as `agent`'s next one."""
|
|
20
|
+
self._actions[agent] = action
|
|
21
|
+
|
|
22
|
+
def get(self, agent: str) -> ScheduledQuotaAction | None:
|
|
23
|
+
"""Return `agent`'s recorded action, or none when none was recorded."""
|
|
24
|
+
return self._actions.get(agent)
|