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,102 @@
|
|
|
1
|
+
"""Which windows and role panes of a task are in a quota wait, from authoritative current facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.domain.ladder import pick_rung
|
|
9
|
+
from shikiru.domain.quota_waits import current_quota_wait_events
|
|
10
|
+
from shikiru.domain.scheduling import WaitReason
|
|
11
|
+
from shikiru.domain.steps import task_acting_role
|
|
12
|
+
from shikiru.domain.tasks import Role, Status
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
from shikiru.clock import Clock
|
|
19
|
+
from shikiru.config.settings import Settings
|
|
20
|
+
from shikiru.domain.quota_waits import QuotaAction
|
|
21
|
+
from shikiru.domain.tasks import Task
|
|
22
|
+
from shikiru.engine.quota_schedule import QuotaSchedule
|
|
23
|
+
from shikiru.store.events import EventLog
|
|
24
|
+
from shikiru.store.quotas import AgentBlock, QuotaStore
|
|
25
|
+
|
|
26
|
+
_QUOTA_ROLES = (Role.LEADER, Role.IMPLEMENTER)
|
|
27
|
+
_TURN_STATUSES = (Status.RUNNING, Status.INTEGRATING)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class RoleQuotaWait:
|
|
32
|
+
"""A role whose agent is quota blocked, with the engine's next action on it when known."""
|
|
33
|
+
|
|
34
|
+
action: QuotaAction | None = None
|
|
35
|
+
at: datetime | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class TaskQuotaWait:
|
|
40
|
+
"""A task in a quota wait and the roles whose agents the wait is about.
|
|
41
|
+
|
|
42
|
+
A queued task waits when any required role is blocked, whichever role acts next; a running
|
|
43
|
+
task waits in the turn of its acting role only. `roles` can be empty while the persisted
|
|
44
|
+
wait reason still names quota, as the task list also shows.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
roles: Mapping[Role, RoleQuotaWait]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QuotaWaits:
|
|
51
|
+
"""Resolves a task's quota wait with the same rules as the task list's quota marker."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
quotas: QuotaStore,
|
|
57
|
+
events: EventLog,
|
|
58
|
+
schedule: QuotaSchedule,
|
|
59
|
+
clock: Clock,
|
|
60
|
+
settings: Settings,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Read blocks from `quotas`, turn facts from `events`, next actions from `schedule`."""
|
|
63
|
+
self._quotas = quotas
|
|
64
|
+
self._events = events
|
|
65
|
+
self._schedule = schedule
|
|
66
|
+
self._clock = clock
|
|
67
|
+
self._settings = settings
|
|
68
|
+
|
|
69
|
+
def resolve(self, task: Task) -> TaskQuotaWait | None:
|
|
70
|
+
"""Return `task`'s quota wait, or none when it is not waiting on quota."""
|
|
71
|
+
if task.status is Status.QUEUED:
|
|
72
|
+
return self._queued(task)
|
|
73
|
+
if task.status in _TURN_STATUSES and task.step is not None:
|
|
74
|
+
return self._running(task)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _queued(self, task: Task) -> TaskQuotaWait | None:
|
|
78
|
+
if task.wait_reason != WaitReason.QUOTA.value:
|
|
79
|
+
return None
|
|
80
|
+
blocks = {block.agent: block for block in self._quotas.active_blocks(self._clock.now())}
|
|
81
|
+
roles = {
|
|
82
|
+
role: self._role_wait(blocks[agent])
|
|
83
|
+
for role in _QUOTA_ROLES
|
|
84
|
+
if (agent := pick_rung(task, role, self._settings).agent) in blocks
|
|
85
|
+
}
|
|
86
|
+
return TaskQuotaWait(roles)
|
|
87
|
+
|
|
88
|
+
def _running(self, task: Task) -> TaskQuotaWait | None:
|
|
89
|
+
role = task_acting_role(task)
|
|
90
|
+
agent = pick_rung(task, role, self._settings).agent
|
|
91
|
+
if current_quota_wait_events(task, role, agent, self._events.latest) is None:
|
|
92
|
+
return None
|
|
93
|
+
block = self._quotas.block_for(agent)
|
|
94
|
+
if block is None or block.until <= self._clock.now():
|
|
95
|
+
return None
|
|
96
|
+
return TaskQuotaWait({role: self._role_wait(block)})
|
|
97
|
+
|
|
98
|
+
def _role_wait(self, block: AgentBlock) -> RoleQuotaWait:
|
|
99
|
+
scheduled = self._schedule.get(block.agent)
|
|
100
|
+
if scheduled is None or scheduled.blocked_at != block.blocked_at:
|
|
101
|
+
return RoleQuotaWait()
|
|
102
|
+
return RoleQuotaWait(scheduled.action, scheduled.at)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Engine restart recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.tasks import Status
|
|
8
|
+
from shikiru.paths import task_root
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from shikiru.domain.tasks import Task
|
|
15
|
+
from shikiru.engine.fault_reporter import FaultReporter
|
|
16
|
+
from shikiru.engine.layout_guard import LayoutGuard
|
|
17
|
+
from shikiru.engine.turn_processes import TurnProcesses
|
|
18
|
+
from shikiru.store.tasks import TaskStore
|
|
19
|
+
|
|
20
|
+
_IN_FLIGHT = (Status.RUNNING, Status.INTEGRATING)
|
|
21
|
+
_TERMINAL = (Status.DONE, Status.CANCELLED)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Reconciler:
|
|
25
|
+
"""Restore windows, end orphaned turn processes and re-run in-flight steps at engine start."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
tasks: TaskStore,
|
|
31
|
+
layout: LayoutGuard,
|
|
32
|
+
processes: TurnProcesses,
|
|
33
|
+
resume_task: Callable[[str], Awaitable[None]],
|
|
34
|
+
finish_task: Callable[[Task], Awaitable[Path | None]],
|
|
35
|
+
restore_escalation: Callable[[Task], Awaitable[None]],
|
|
36
|
+
restore_pull_request_closes: Callable[[], None],
|
|
37
|
+
faults: FaultReporter,
|
|
38
|
+
project_root: Path,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Resume in-flight tasks, finish terminal ones, restore escalations of escalated ones.
|
|
41
|
+
|
|
42
|
+
A fault in one step or task is recorded through `faults`; the rest goes on.
|
|
43
|
+
"""
|
|
44
|
+
self._tasks = tasks
|
|
45
|
+
self._layout = layout
|
|
46
|
+
self._processes = processes
|
|
47
|
+
self._resume_task = resume_task
|
|
48
|
+
self._finish_task = finish_task
|
|
49
|
+
self._restore_escalation = restore_escalation
|
|
50
|
+
self._restore_pull_request_closes = restore_pull_request_closes
|
|
51
|
+
self._faults = faults
|
|
52
|
+
self._project_root = project_root
|
|
53
|
+
self._orphans_ended = False
|
|
54
|
+
|
|
55
|
+
async def reconcile(self) -> None:
|
|
56
|
+
"""Bring a restarted engine back in line with the persisted state.
|
|
57
|
+
|
|
58
|
+
Pending commands need nothing here: the engine loop processes them once this has
|
|
59
|
+
completed. A fault in one step or one task is recorded and never stops the others.
|
|
60
|
+
Ending the orphaned turn processes is the exception: a fault there aborts the reconcile,
|
|
61
|
+
which is retried before anything starts, so no flow runs beside a live orphan and none
|
|
62
|
+
ends a turn the engine started itself.
|
|
63
|
+
"""
|
|
64
|
+
active = self._tasks.list_active()
|
|
65
|
+
terminal = [
|
|
66
|
+
task
|
|
67
|
+
for task in self._tasks.list_all()
|
|
68
|
+
if task.status in _TERMINAL and task_root(self._project_root, task.id).exists()
|
|
69
|
+
]
|
|
70
|
+
async with self._faults.isolate("reconcile layout"):
|
|
71
|
+
await self._layout.restore(active)
|
|
72
|
+
if not self._orphans_ended:
|
|
73
|
+
await self._processes.end_orphans()
|
|
74
|
+
self._orphans_ended = True
|
|
75
|
+
for task in terminal:
|
|
76
|
+
async with self._faults.isolate("reconcile cleanup", task_id=task.id):
|
|
77
|
+
await self._finish_task(task)
|
|
78
|
+
async with self._faults.isolate("reconcile pull request closes"):
|
|
79
|
+
self._restore_pull_request_closes()
|
|
80
|
+
await self.restore_escalations()
|
|
81
|
+
for task in active:
|
|
82
|
+
if task.status in _IN_FLIGHT and not task.held:
|
|
83
|
+
async with self._faults.isolate("reconcile resume", task_id=task.id):
|
|
84
|
+
await self._resume_task(task.id)
|
|
85
|
+
|
|
86
|
+
async def restore_escalations(self) -> None:
|
|
87
|
+
"""Give every escalated task an open escalation with its packet on disk."""
|
|
88
|
+
for task in self._tasks.list_active():
|
|
89
|
+
if task.status is Status.ESCALATED:
|
|
90
|
+
async with self._faults.isolate("escalation restore", task_id=task.id):
|
|
91
|
+
await self._restore_escalation(task)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Recovery of a turn that ended without a report: retries, quota waits and rung moves."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.agents.contracts import FailureKind
|
|
11
|
+
from shikiru.domain.events import EventKind
|
|
12
|
+
from shikiru.domain.ladder import move_up, pick_rung
|
|
13
|
+
from shikiru.domain.recovery import (
|
|
14
|
+
QUOTA_MARGIN,
|
|
15
|
+
Backoff,
|
|
16
|
+
Escalate,
|
|
17
|
+
Failed,
|
|
18
|
+
NextRung,
|
|
19
|
+
RecoveryPolicy,
|
|
20
|
+
ResumeTurn,
|
|
21
|
+
RetryTurn,
|
|
22
|
+
TurnCounters,
|
|
23
|
+
WaitForQuota,
|
|
24
|
+
decide,
|
|
25
|
+
quota_block_until,
|
|
26
|
+
with_known_quota_reset,
|
|
27
|
+
)
|
|
28
|
+
from shikiru.domain.steps import Transition
|
|
29
|
+
from shikiru.domain.tasks import EscalationReason
|
|
30
|
+
from shikiru.engine.events import emit_turn_failed
|
|
31
|
+
from shikiru.engine.turn_context import ORDINARY_TURN, NextTurn
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
|
|
36
|
+
from shikiru.clock import Clock
|
|
37
|
+
from shikiru.config.settings import Settings
|
|
38
|
+
from shikiru.domain.recovery import Action, TurnResult
|
|
39
|
+
from shikiru.domain.tasks import Role, Step, Task
|
|
40
|
+
from shikiru.engine.quota_reconciler import QuotaReconciler
|
|
41
|
+
from shikiru.engine.transitions import TransitionWriter
|
|
42
|
+
from shikiru.engine.turn_execution import TurnExecution
|
|
43
|
+
from shikiru.store.events import EventLog
|
|
44
|
+
from shikiru.store.quotas import QuotaStore
|
|
45
|
+
from shikiru.store.tasks import TaskStore
|
|
46
|
+
|
|
47
|
+
QUOTA_BLOCK_REASON = "quota"
|
|
48
|
+
_QUOTA_WAIT_EXHAUSTED_MESSAGE = "quota wait exceeded"
|
|
49
|
+
_CONTINUE = NextTurn(continue_turn=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class RecoveryStep:
|
|
54
|
+
"""What the flow does after a failed turn: run again, re-decide on `execution` or escalate."""
|
|
55
|
+
|
|
56
|
+
task: Task
|
|
57
|
+
counters: TurnCounters
|
|
58
|
+
execution: TurnExecution | None = None
|
|
59
|
+
escalation: EscalationReason | None = None
|
|
60
|
+
next_turn: NextTurn = ORDINARY_TURN
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TurnRecovery:
|
|
64
|
+
"""Decides and carries out the recovery of a failed turn; escalation stays with the flow."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
tasks: TaskStore,
|
|
70
|
+
events: EventLog,
|
|
71
|
+
quota: QuotaStore,
|
|
72
|
+
reconciler: QuotaReconciler,
|
|
73
|
+
clock: Clock,
|
|
74
|
+
writer: TransitionWriter,
|
|
75
|
+
settings: Settings,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Recover through the ladder, the quota blocks and the clock."""
|
|
78
|
+
self._tasks = tasks
|
|
79
|
+
self._events = events
|
|
80
|
+
self._quota = quota
|
|
81
|
+
self._reconciler = reconciler
|
|
82
|
+
self._clock = clock
|
|
83
|
+
self._writer = writer
|
|
84
|
+
self._settings = settings
|
|
85
|
+
|
|
86
|
+
async def recover(
|
|
87
|
+
self,
|
|
88
|
+
task: Task,
|
|
89
|
+
step: Step,
|
|
90
|
+
role: Role,
|
|
91
|
+
execution: TurnExecution,
|
|
92
|
+
counters: TurnCounters,
|
|
93
|
+
) -> RecoveryStep:
|
|
94
|
+
"""Apply the recovery `domain.recovery.decide` chooses for `execution`'s failed result."""
|
|
95
|
+
execution = self._with_known_reset(execution)
|
|
96
|
+
result = execution.result
|
|
97
|
+
emit_turn_failed(
|
|
98
|
+
self._events,
|
|
99
|
+
task,
|
|
100
|
+
step,
|
|
101
|
+
result,
|
|
102
|
+
rung=execution.rung,
|
|
103
|
+
run_id=execution.run_id,
|
|
104
|
+
turn=execution.turn_dir.name,
|
|
105
|
+
role=execution.role,
|
|
106
|
+
)
|
|
107
|
+
action = decide(
|
|
108
|
+
result,
|
|
109
|
+
counters,
|
|
110
|
+
self._policy(task, role, quota_wait_exhausted=execution.quota_wait_exhausted),
|
|
111
|
+
)
|
|
112
|
+
if isinstance(result, Failed) and result.kind is FailureKind.QUOTA:
|
|
113
|
+
self._block_quota(task, role, result.resets_at)
|
|
114
|
+
return await self._apply(
|
|
115
|
+
task, step, role, execution=execution, counters=counters, action=action
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _with_known_reset(self, execution: TurnExecution) -> TurnExecution:
|
|
119
|
+
windows = self._quota.windows_for(execution.rung.agent)
|
|
120
|
+
result = with_known_quota_reset(execution.result, windows, self._clock.now())
|
|
121
|
+
return dataclasses.replace(execution, result=result)
|
|
122
|
+
|
|
123
|
+
def _policy(self, task: Task, role: Role, *, quota_wait_exhausted: bool) -> RecoveryPolicy:
|
|
124
|
+
current = move_up(task, role, self._settings)
|
|
125
|
+
different = move_up(
|
|
126
|
+
task, role, self._settings, different_agent=True, blocked_agents=self._blocked_agents()
|
|
127
|
+
)
|
|
128
|
+
retries = self._settings.orchestration.retries
|
|
129
|
+
return RecoveryPolicy(
|
|
130
|
+
now=self._clock.now(),
|
|
131
|
+
max_quota_wait=timedelta(seconds=self._settings.orchestration.timeouts.max_quota_wait),
|
|
132
|
+
has_next_rung=current is not None,
|
|
133
|
+
has_next_rung_different_agent=different is not None,
|
|
134
|
+
unavailable_initial_seconds=retries.unavailable_initial_seconds,
|
|
135
|
+
unavailable_multiplier=retries.unavailable_multiplier,
|
|
136
|
+
unavailable_max_seconds=retries.unavailable_max_seconds,
|
|
137
|
+
unavailable_max_retries=retries.unavailable_max_retries,
|
|
138
|
+
quota_wait_exhausted=quota_wait_exhausted,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def _apply(
|
|
142
|
+
self,
|
|
143
|
+
task: Task,
|
|
144
|
+
step: Step,
|
|
145
|
+
role: Role,
|
|
146
|
+
*,
|
|
147
|
+
execution: TurnExecution,
|
|
148
|
+
counters: TurnCounters,
|
|
149
|
+
action: Action,
|
|
150
|
+
) -> RecoveryStep:
|
|
151
|
+
if isinstance(action, NextRung):
|
|
152
|
+
return await self._next_rung(task, step, role, execution.result, action)
|
|
153
|
+
if isinstance(action, Escalate):
|
|
154
|
+
return RecoveryStep(task, TurnCounters(), escalation=action.reason)
|
|
155
|
+
return await self._retry(task, role, execution, counters, action)
|
|
156
|
+
|
|
157
|
+
async def _retry(
|
|
158
|
+
self,
|
|
159
|
+
task: Task,
|
|
160
|
+
role: Role,
|
|
161
|
+
execution: TurnExecution,
|
|
162
|
+
counters: TurnCounters,
|
|
163
|
+
action: WaitForQuota | Backoff | ResumeTurn | RetryTurn,
|
|
164
|
+
) -> RecoveryStep:
|
|
165
|
+
if isinstance(action, RetryTurn):
|
|
166
|
+
return RecoveryStep(
|
|
167
|
+
task, dataclasses.replace(counters, other_retries=counters.other_retries + 1)
|
|
168
|
+
)
|
|
169
|
+
if isinstance(action, Backoff):
|
|
170
|
+
await self._clock.sleep(action.delay.total_seconds())
|
|
171
|
+
retried = dataclasses.replace(
|
|
172
|
+
counters, unavailable_retries=counters.unavailable_retries + 1
|
|
173
|
+
)
|
|
174
|
+
return RecoveryStep(self._tasks.require(task.id), retried)
|
|
175
|
+
if isinstance(action, ResumeTurn):
|
|
176
|
+
resumed = dataclasses.replace(counters, stall_recoveries=counters.stall_recoveries + 1)
|
|
177
|
+
return RecoveryStep(self._tasks.require(task.id), resumed, next_turn=_CONTINUE)
|
|
178
|
+
terminal = await self._wait_for_quota(task, role)
|
|
179
|
+
if terminal is None:
|
|
180
|
+
return RecoveryStep(self._tasks.require(task.id), counters, next_turn=_CONTINUE)
|
|
181
|
+
exhausted = dataclasses.replace(execution, result=terminal, quota_wait_exhausted=True)
|
|
182
|
+
return RecoveryStep(self._tasks.require(task.id), counters, execution=exhausted)
|
|
183
|
+
|
|
184
|
+
async def _next_rung(
|
|
185
|
+
self, task: Task, step: Step, role: Role, result: TurnResult, action: NextRung
|
|
186
|
+
) -> RecoveryStep:
|
|
187
|
+
updated = await self._move_rung(task, step, role, action.different_agent, result)
|
|
188
|
+
if updated is not None:
|
|
189
|
+
return RecoveryStep(
|
|
190
|
+
updated, TurnCounters(), next_turn=NextTurn(handoff=action.different_agent)
|
|
191
|
+
)
|
|
192
|
+
reason = (
|
|
193
|
+
EscalationReason.QUOTA_EXHAUSTED
|
|
194
|
+
if action.different_agent
|
|
195
|
+
else EscalationReason.LADDER_EXHAUSTED
|
|
196
|
+
)
|
|
197
|
+
return RecoveryStep(task, TurnCounters(), escalation=reason)
|
|
198
|
+
|
|
199
|
+
async def _move_rung(
|
|
200
|
+
self,
|
|
201
|
+
task: Task,
|
|
202
|
+
step: Step,
|
|
203
|
+
role: Role,
|
|
204
|
+
different_agent: bool,
|
|
205
|
+
result: TurnResult,
|
|
206
|
+
) -> Task | None:
|
|
207
|
+
move = move_up(
|
|
208
|
+
task,
|
|
209
|
+
role,
|
|
210
|
+
self._settings,
|
|
211
|
+
different_agent=different_agent,
|
|
212
|
+
blocked_agents=self._blocked_agents() if different_agent else frozenset(),
|
|
213
|
+
)
|
|
214
|
+
if move is None:
|
|
215
|
+
return None
|
|
216
|
+
old_rung = pick_rung(task, role, self._settings)
|
|
217
|
+
transition = dataclasses.replace(
|
|
218
|
+
Transition.stay(task, step=step, status=task.status),
|
|
219
|
+
counters=move.counters,
|
|
220
|
+
rung_index=move.rung_index,
|
|
221
|
+
cleared_overrides=move.cleared_overrides,
|
|
222
|
+
)
|
|
223
|
+
updated = await self._writer.apply(task, transition, None)
|
|
224
|
+
new_rung = pick_rung(updated, role, self._settings)
|
|
225
|
+
self._events.append(
|
|
226
|
+
EventKind.RUNG_CHANGED,
|
|
227
|
+
task_id=task.id,
|
|
228
|
+
message=f"{task.id} {role.value} moved from {old_rung.model} to {new_rung.model}",
|
|
229
|
+
data={
|
|
230
|
+
"role": role.value,
|
|
231
|
+
"from": old_rung.model,
|
|
232
|
+
"to": new_rung.model,
|
|
233
|
+
"different_agent": different_agent,
|
|
234
|
+
"result": type(result).__name__,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
return updated
|
|
238
|
+
|
|
239
|
+
def _blocked_agents(self) -> frozenset[str]:
|
|
240
|
+
return frozenset(block.agent for block in self._quota.active_blocks(self._clock.now()))
|
|
241
|
+
|
|
242
|
+
def _block_quota(self, task: Task, role: Role, resets_at: datetime | None) -> None:
|
|
243
|
+
agent = pick_rung(task, role, self._settings).agent
|
|
244
|
+
now = self._clock.now()
|
|
245
|
+
until = quota_block_until(resets_at, now)
|
|
246
|
+
self._quota.block(agent, until, QUOTA_BLOCK_REASON, now)
|
|
247
|
+
self._events.append(
|
|
248
|
+
EventKind.QUOTA_BLOCKED,
|
|
249
|
+
task_id=task.id,
|
|
250
|
+
message=f"{agent} quota blocked until {until.isoformat()}",
|
|
251
|
+
data={"agent": agent, "until": until.isoformat(), "reason": QUOTA_BLOCK_REASON},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
async def _wait_for_quota(self, task: Task, role: Role) -> Failed | None:
|
|
255
|
+
agent = pick_rung(task, role, self._settings).agent
|
|
256
|
+
deadline = self._clock.now() + timedelta(
|
|
257
|
+
seconds=self._settings.orchestration.timeouts.max_quota_wait
|
|
258
|
+
)
|
|
259
|
+
while True:
|
|
260
|
+
current = self._quota.block_for(agent)
|
|
261
|
+
now = self._clock.now()
|
|
262
|
+
if current is None:
|
|
263
|
+
return None
|
|
264
|
+
if current.until > now:
|
|
265
|
+
if now >= deadline:
|
|
266
|
+
return Failed(
|
|
267
|
+
kind=FailureKind.QUOTA,
|
|
268
|
+
resets_at=current.until - QUOTA_MARGIN,
|
|
269
|
+
message=_QUOTA_WAIT_EXHAUSTED_MESSAGE,
|
|
270
|
+
)
|
|
271
|
+
remaining = min(current.until, deadline) - now
|
|
272
|
+
delay = min(remaining.total_seconds(), self._reconciler.interval_seconds)
|
|
273
|
+
self._reconciler.expect_check(agent, now + timedelta(seconds=delay))
|
|
274
|
+
await self._clock.sleep(delay)
|
|
275
|
+
await self._reconciler.refresh(agent)
|
|
276
|
+
continue
|
|
277
|
+
self._quota.clear_block(agent)
|
|
278
|
+
self._events.append(
|
|
279
|
+
EventKind.QUOTA_CLEARED,
|
|
280
|
+
task_id=task.id,
|
|
281
|
+
message=f"{agent} quota block cleared",
|
|
282
|
+
data={"agent": agent},
|
|
283
|
+
)
|
|
284
|
+
return None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Prunes finished history so a long-running engine stays bounded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from shikiru.domain.tasks import Status
|
|
12
|
+
from shikiru.paths import run_dir, task_root
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from shikiru.clock import Clock
|
|
20
|
+
from shikiru.config.settings import RetentionConfig
|
|
21
|
+
from shikiru.store.notification_cursor import NotificationCursor
|
|
22
|
+
from shikiru.store.retention import StateRetention
|
|
23
|
+
from shikiru.store.tasks import TaskStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Retention:
|
|
27
|
+
"""Removes long-finished tasks' run directories and events and old commands, periodically.
|
|
28
|
+
|
|
29
|
+
One pass runs every `interval_seconds`, also removing old engine events. A task's files and
|
|
30
|
+
events go only when the task is `done` or `cancelled` past the horizon and nothing unresolved
|
|
31
|
+
still needs them: no open escalation, no worktree kept for its unintegrated work, no hold, no
|
|
32
|
+
pull request of a cancelled task left open. Run directories go before their events, so an
|
|
33
|
+
interrupted pass is simply repeated.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
store: StateRetention,
|
|
40
|
+
tasks: TaskStore,
|
|
41
|
+
cursor: NotificationCursor,
|
|
42
|
+
project_root: Path,
|
|
43
|
+
has_open_pull_request: Callable[[str], bool],
|
|
44
|
+
clock: Clock,
|
|
45
|
+
settings: RetentionConfig,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Prune `store` and the run directories under `project_root` on `clock`'s schedule."""
|
|
48
|
+
self._store = store
|
|
49
|
+
self._tasks = tasks
|
|
50
|
+
self._cursor = cursor
|
|
51
|
+
self._project_root = project_root
|
|
52
|
+
self._has_open_pull_request = has_open_pull_request
|
|
53
|
+
self._clock = clock
|
|
54
|
+
self._settings = settings
|
|
55
|
+
self._next_run: datetime | None = None
|
|
56
|
+
self._logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
async def run_if_due(self) -> None:
|
|
59
|
+
"""Run one pruning pass when `interval_seconds` have passed since the last one."""
|
|
60
|
+
now = self._clock.now()
|
|
61
|
+
if self._next_run is not None and now < self._next_run:
|
|
62
|
+
return
|
|
63
|
+
await self._prune(now)
|
|
64
|
+
self._next_run = now + timedelta(seconds=self._settings.interval_seconds)
|
|
65
|
+
|
|
66
|
+
async def _prune(self, now: datetime) -> None:
|
|
67
|
+
delivered_up_to = self._cursor.read()
|
|
68
|
+
if delivered_up_to is None:
|
|
69
|
+
return
|
|
70
|
+
horizon = now - timedelta(days=self._settings.finished_task_days)
|
|
71
|
+
settled = [
|
|
72
|
+
task_id
|
|
73
|
+
for task_id in self._store.settled_task_ids(horizon)
|
|
74
|
+
if self._releasable(task_id)
|
|
75
|
+
]
|
|
76
|
+
await asyncio.to_thread(_remove_run_dirs, [run_dir(self._project_root, t) for t in settled])
|
|
77
|
+
events = self._store.delete_task_events(settled, delivered_up_to=delivered_up_to)
|
|
78
|
+
events += self._store.delete_engine_events(
|
|
79
|
+
now - timedelta(days=self._settings.event_days), delivered_up_to=delivered_up_to
|
|
80
|
+
)
|
|
81
|
+
commands = self._store.delete_finished_commands(
|
|
82
|
+
now - timedelta(days=self._settings.command_days)
|
|
83
|
+
)
|
|
84
|
+
if settled or events or commands:
|
|
85
|
+
self._logger.info(
|
|
86
|
+
"retention pruned %d tasks, %d events, %d commands", len(settled), events, commands
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _releasable(self, task_id: str) -> bool:
|
|
90
|
+
task = self._tasks.get(task_id)
|
|
91
|
+
return (
|
|
92
|
+
task is not None
|
|
93
|
+
and not task.held
|
|
94
|
+
and not task_root(self._project_root, task_id).exists()
|
|
95
|
+
and not (task.status is Status.CANCELLED and self._has_open_pull_request(task_id))
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _remove_run_dirs(directories: list[Path]) -> None:
|
|
100
|
+
for directory in directories:
|
|
101
|
+
if directory.exists():
|
|
102
|
+
shutil.rmtree(directory)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Off-tick task root preparation under a per-tick budget."""
|
|
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
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Collection
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from shikiru.clock import Clock
|
|
15
|
+
from shikiru.domain.tasks import RepoState
|
|
16
|
+
from shikiru.workspace.roots import TaskRootBuilder
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RootPreparation:
|
|
20
|
+
"""Builds task roots on their own tasks so a slow fetch or cache copy never holds the tick.
|
|
21
|
+
|
|
22
|
+
A tick may wait `budget_seconds` in total for the roots it starts; a root that is not ready
|
|
23
|
+
by then keeps building and is collected by a later tick, which never waits for it again. At most
|
|
24
|
+
one build runs at a time and none starts once a tick's budget is spent, so a hung remote costs
|
|
25
|
+
one budget, not one per ready task and tick, and builds in one repository never overlap.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, roots: TaskRootBuilder, clock: Clock, *, budget_seconds: float) -> None:
|
|
29
|
+
"""Build through `roots`, measuring each tick's budget on `clock`."""
|
|
30
|
+
self._roots = roots
|
|
31
|
+
self._clock = clock
|
|
32
|
+
self._budget = timedelta(seconds=budget_seconds)
|
|
33
|
+
self._expires: datetime = clock.now()
|
|
34
|
+
self._builds: dict[str, asyncio.Task[RepoState]] = {}
|
|
35
|
+
|
|
36
|
+
def begin_tick(self, queued: Collection[str]) -> None:
|
|
37
|
+
"""Start a tick's budget and let go of the builds of tasks that are no longer `queued`.
|
|
38
|
+
|
|
39
|
+
A build in flight for such a task (cancelled meanwhile) is cancelled, and counts as
|
|
40
|
+
running until it has ended; a finished one is dropped.
|
|
41
|
+
"""
|
|
42
|
+
self._expires = self._clock.now() + self._budget
|
|
43
|
+
for task_id, build in tuple(self._builds.items()):
|
|
44
|
+
if task_id in queued:
|
|
45
|
+
continue
|
|
46
|
+
if not build.done():
|
|
47
|
+
build.cancel()
|
|
48
|
+
continue
|
|
49
|
+
del self._builds[task_id]
|
|
50
|
+
if not build.cancelled():
|
|
51
|
+
build.exception()
|
|
52
|
+
|
|
53
|
+
async def prepare(self, task_id: str, title: str, seed_branch: str | None) -> RepoState | None:
|
|
54
|
+
"""Return `task_id`'s built root, or `None` while it is not ready within this tick.
|
|
55
|
+
|
|
56
|
+
Raises what the build raised.
|
|
57
|
+
"""
|
|
58
|
+
build = self._builds.get(task_id)
|
|
59
|
+
if build is None:
|
|
60
|
+
remaining = (self._expires - self._clock.now()).total_seconds()
|
|
61
|
+
if remaining <= 0 or any(not other.done() for other in self._builds.values()):
|
|
62
|
+
return None
|
|
63
|
+
build = asyncio.create_task(self._roots.build(task_id, title, seed_branch=seed_branch))
|
|
64
|
+
self._builds[task_id] = build
|
|
65
|
+
await self._wait(build, remaining)
|
|
66
|
+
if not build.done():
|
|
67
|
+
return None
|
|
68
|
+
del self._builds[task_id]
|
|
69
|
+
return build.result()
|
|
70
|
+
|
|
71
|
+
async def shutdown(self) -> None:
|
|
72
|
+
"""Cancel every build in flight and wait until its processes are gone."""
|
|
73
|
+
builds = tuple(self._builds.values())
|
|
74
|
+
self._builds.clear()
|
|
75
|
+
for build in builds:
|
|
76
|
+
build.cancel()
|
|
77
|
+
await asyncio.gather(*builds, return_exceptions=True)
|
|
78
|
+
|
|
79
|
+
async def _wait(self, build: asyncio.Task[RepoState], seconds: float) -> None:
|
|
80
|
+
timer = asyncio.create_task(self._clock.sleep(seconds))
|
|
81
|
+
try:
|
|
82
|
+
await asyncio.wait({build, timer}, return_when=asyncio.FIRST_COMPLETED)
|
|
83
|
+
finally:
|
|
84
|
+
timer.cancel()
|
|
85
|
+
with suppress(asyncio.CancelledError):
|
|
86
|
+
await timer
|