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,320 @@
|
|
|
1
|
+
"""Task dispatch, gate evaluation and slot ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from shikiru.domain.events import EventKind
|
|
12
|
+
from shikiru.domain.faults import next_backoff
|
|
13
|
+
from shikiru.domain.ladder import move_up, pick_rung
|
|
14
|
+
from shikiru.domain.scheduling import (
|
|
15
|
+
Dispatch,
|
|
16
|
+
ReadySchedule,
|
|
17
|
+
SchedulingGates,
|
|
18
|
+
WaitReason,
|
|
19
|
+
occupied_slot_ids,
|
|
20
|
+
pick_ready,
|
|
21
|
+
task_agents,
|
|
22
|
+
)
|
|
23
|
+
from shikiru.domain.steps import Transition
|
|
24
|
+
from shikiru.domain.tasks import Role, Status, StatusReason, Step, Task
|
|
25
|
+
from shikiru.engine.events import emit_step_started
|
|
26
|
+
from shikiru.errors import InvariantError
|
|
27
|
+
from shikiru.workspace.base_refs import BaseFetchFailed
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
|
|
32
|
+
from shikiru.clock import Clock
|
|
33
|
+
from shikiru.config.settings import Settings
|
|
34
|
+
from shikiru.engine.fault_reporter import FaultReporter
|
|
35
|
+
from shikiru.engine.layout_guard import LayoutGuard
|
|
36
|
+
from shikiru.engine.quota_reconciler import QuotaReconciler
|
|
37
|
+
from shikiru.engine.root_preparation import RootPreparation
|
|
38
|
+
from shikiru.engine.titles import TaskTitles
|
|
39
|
+
from shikiru.store.events import EventLog
|
|
40
|
+
from shikiru.store.quotas import QuotaStore
|
|
41
|
+
from shikiru.store.tasks import TaskStore
|
|
42
|
+
from shikiru.telemetry.resources import ResourceSampler
|
|
43
|
+
|
|
44
|
+
DependencyCallback = Callable[[Task], Awaitable[None]]
|
|
45
|
+
DispatchFailureCallback = Callable[[Task, Exception], Awaitable[None]]
|
|
46
|
+
MAX_DISPATCH_FAILURES = 3
|
|
47
|
+
FETCH_FAULT_SCOPE = "dispatch fetch"
|
|
48
|
+
_QUOTA_DECORATED_STATUSES = (Status.QUEUED, Status.RUNNING, Status.INTEGRATING)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Scheduler:
|
|
52
|
+
"""Builds the ready set, persists wait reasons and dispatches eligible tasks."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
tasks: TaskStore,
|
|
58
|
+
settings: Settings,
|
|
59
|
+
quotas: QuotaStore,
|
|
60
|
+
reconciler: QuotaReconciler,
|
|
61
|
+
resources: ResourceSampler,
|
|
62
|
+
clock: Clock,
|
|
63
|
+
layout: LayoutGuard,
|
|
64
|
+
roots: RootPreparation,
|
|
65
|
+
events: EventLog,
|
|
66
|
+
on_dependency_cancelled: DependencyCallback,
|
|
67
|
+
on_dispatch_failure: DispatchFailureCallback,
|
|
68
|
+
titles: TaskTitles,
|
|
69
|
+
faults: FaultReporter,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Inject state stores, gates, and the task-window dispatch boundary.
|
|
72
|
+
|
|
73
|
+
A dispatch whose base fetch fails is retried on a growing backoff, recorded through
|
|
74
|
+
`faults`, and never counts toward `MAX_DISPATCH_FAILURES`.
|
|
75
|
+
"""
|
|
76
|
+
self._tasks = tasks
|
|
77
|
+
self._settings = settings
|
|
78
|
+
self._quotas = quotas
|
|
79
|
+
self._reconciler = reconciler
|
|
80
|
+
self._resources = resources
|
|
81
|
+
self._clock = clock
|
|
82
|
+
self._layout = layout
|
|
83
|
+
self._roots = roots
|
|
84
|
+
self._events = events
|
|
85
|
+
self._on_dependency_cancelled = on_dependency_cancelled
|
|
86
|
+
self._on_dispatch_failure = on_dispatch_failure
|
|
87
|
+
self._titles = titles
|
|
88
|
+
self._faults = faults
|
|
89
|
+
self._dispatch_failures: dict[str, int] = {}
|
|
90
|
+
self._fetch_backoffs: dict[str, float] = {}
|
|
91
|
+
self._fetch_retry_at: dict[str, datetime] = {}
|
|
92
|
+
self._logger = logging.getLogger(__name__)
|
|
93
|
+
|
|
94
|
+
async def run_once(self) -> ReadySchedule:
|
|
95
|
+
"""Evaluate gates, persist wait reasons and dispatch every eligible task."""
|
|
96
|
+
all_tasks = self._tasks.list_all()
|
|
97
|
+
self._request_quota_refreshes(all_tasks)
|
|
98
|
+
gates = self._gates(all_tasks)
|
|
99
|
+
schedule = pick_ready(all_tasks, gates)
|
|
100
|
+
if self._advance_quota_blocked(all_tasks, schedule):
|
|
101
|
+
all_tasks = self._tasks.list_all()
|
|
102
|
+
schedule = pick_ready(all_tasks, self._gates(all_tasks))
|
|
103
|
+
self._roots.begin_tick({task.id for task in all_tasks if task.status is Status.QUEUED})
|
|
104
|
+
self._persist_wait_reasons(all_tasks, schedule)
|
|
105
|
+
await self._sync_quota_decorations()
|
|
106
|
+
for task_id in schedule.dependency_cancelled:
|
|
107
|
+
task = self._tasks.get(task_id)
|
|
108
|
+
if task is None:
|
|
109
|
+
message = f"{task_id} vanished before dependency cancellation"
|
|
110
|
+
raise InvariantError(message)
|
|
111
|
+
await self._on_dependency_cancelled(task)
|
|
112
|
+
successful_dispatches: list[Dispatch] = []
|
|
113
|
+
for dispatch in schedule.dispatches:
|
|
114
|
+
task = self._tasks.get(dispatch.task_id)
|
|
115
|
+
if task is None:
|
|
116
|
+
message = f"{dispatch.task_id} vanished before dispatch"
|
|
117
|
+
raise InvariantError(message)
|
|
118
|
+
if self._awaits_fetch_retry(task.id):
|
|
119
|
+
continue
|
|
120
|
+
if await self._dispatch(task, dispatch.slot):
|
|
121
|
+
successful_dispatches.append(dispatch)
|
|
122
|
+
return dataclasses.replace(schedule, dispatches=tuple(successful_dispatches))
|
|
123
|
+
|
|
124
|
+
async def shutdown(self) -> None:
|
|
125
|
+
"""Stop the quota probes and the task root builds still in flight."""
|
|
126
|
+
await self._reconciler.shutdown()
|
|
127
|
+
await self._roots.shutdown()
|
|
128
|
+
|
|
129
|
+
def _request_quota_refreshes(self, tasks: list[Task]) -> None:
|
|
130
|
+
now = self._clock.now()
|
|
131
|
+
demands: dict[str, datetime | None] = {}
|
|
132
|
+
for block in self._quotas.active_blocks(now):
|
|
133
|
+
queued = [
|
|
134
|
+
task
|
|
135
|
+
for task in tasks
|
|
136
|
+
if task.status is Status.QUEUED
|
|
137
|
+
and block.agent in dataclasses.astuple(task_agents(task, self._settings))
|
|
138
|
+
]
|
|
139
|
+
if queued:
|
|
140
|
+
requeues = [task.requeued_at for task in queued if task.requeued_at is not None]
|
|
141
|
+
demands[block.agent] = max(requeues, default=None)
|
|
142
|
+
for agent, demanded_at in demands.items():
|
|
143
|
+
self._reconciler.request_refresh(agent, demanded_at=demanded_at)
|
|
144
|
+
|
|
145
|
+
def _advance_quota_blocked(self, tasks: list[Task], schedule: ReadySchedule) -> bool:
|
|
146
|
+
now = self._clock.now()
|
|
147
|
+
blocks = {block.agent: block for block in self._quotas.active_blocks(now)}
|
|
148
|
+
blocked_agents = frozenset(blocks)
|
|
149
|
+
wait_limit = now + timedelta(seconds=self._settings.orchestration.timeouts.max_quota_wait)
|
|
150
|
+
changed = False
|
|
151
|
+
for queued in tasks:
|
|
152
|
+
if schedule.wait_reasons.get(queued.id) is not WaitReason.QUOTA:
|
|
153
|
+
continue
|
|
154
|
+
task = queued
|
|
155
|
+
for role in (Role.LEADER, Role.IMPLEMENTER):
|
|
156
|
+
old_rung = pick_rung(task, role, self._settings)
|
|
157
|
+
block = blocks.get(old_rung.agent)
|
|
158
|
+
if block is None or block.until <= wait_limit:
|
|
159
|
+
continue
|
|
160
|
+
if self._reconciler.awaiting_demanded_probe(block):
|
|
161
|
+
continue
|
|
162
|
+
move = move_up(
|
|
163
|
+
task, role, self._settings, different_agent=True, blocked_agents=blocked_agents
|
|
164
|
+
)
|
|
165
|
+
if move is None:
|
|
166
|
+
continue
|
|
167
|
+
transition = dataclasses.replace(
|
|
168
|
+
Transition.stay(task, step=task.step, status=task.status),
|
|
169
|
+
counters=move.counters,
|
|
170
|
+
rung_index=move.rung_index,
|
|
171
|
+
cleared_overrides=move.cleared_overrides,
|
|
172
|
+
)
|
|
173
|
+
self._tasks.apply_transition(task.id, transition)
|
|
174
|
+
task = self._tasks.require(task.id)
|
|
175
|
+
new_rung = pick_rung(task, role, self._settings)
|
|
176
|
+
self._events.append(
|
|
177
|
+
EventKind.RUNG_CHANGED,
|
|
178
|
+
task_id=task.id,
|
|
179
|
+
message=(
|
|
180
|
+
f"{task.id} {role.value} moved from {old_rung.model} to {new_rung.model}"
|
|
181
|
+
),
|
|
182
|
+
data={
|
|
183
|
+
"role": role.value,
|
|
184
|
+
"from": old_rung.model,
|
|
185
|
+
"to": new_rung.model,
|
|
186
|
+
"different_agent": True,
|
|
187
|
+
"reason": WaitReason.QUOTA.value,
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
changed = True
|
|
191
|
+
return changed
|
|
192
|
+
|
|
193
|
+
async def _sync_quota_decorations(self) -> None:
|
|
194
|
+
for task in self._tasks.list_all():
|
|
195
|
+
if task.window_id is not None and task.status in _QUOTA_DECORATED_STATUSES:
|
|
196
|
+
await self._titles.sync_quota(task)
|
|
197
|
+
|
|
198
|
+
def _gates(self, tasks: list[Task]) -> SchedulingGates:
|
|
199
|
+
slots = frozenset(range(self._settings.orchestration.max_parallel)) - occupied_slot_ids(
|
|
200
|
+
tasks
|
|
201
|
+
)
|
|
202
|
+
blocked = frozenset(block.agent for block in self._quotas.active_blocks(self._clock.now()))
|
|
203
|
+
agents = {
|
|
204
|
+
task.id: task_agents(task, self._settings)
|
|
205
|
+
for task in tasks
|
|
206
|
+
if task.status is Status.QUEUED
|
|
207
|
+
}
|
|
208
|
+
return SchedulingGates(
|
|
209
|
+
free_slots=slots,
|
|
210
|
+
resources_ok=self._resources.resources_ok(self._settings.orchestration.resources),
|
|
211
|
+
blocked_agents=blocked,
|
|
212
|
+
agents=agents,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _persist_wait_reasons(self, tasks: list[Task], schedule: ReadySchedule) -> None:
|
|
216
|
+
reasons = schedule.wait_reasons
|
|
217
|
+
for task in tasks:
|
|
218
|
+
if task.status is not Status.QUEUED:
|
|
219
|
+
continue
|
|
220
|
+
reason = reasons.get(task.id)
|
|
221
|
+
if task.wait_reason != (reason.value if reason is not None else None):
|
|
222
|
+
self._tasks.set_wait_reason(task.id, reason)
|
|
223
|
+
|
|
224
|
+
async def _dispatch(self, task: Task, slot: int) -> bool:
|
|
225
|
+
current = task
|
|
226
|
+
try:
|
|
227
|
+
repo = task.repo
|
|
228
|
+
if repo is None:
|
|
229
|
+
repo = await self._roots.prepare(task.id, task.title, self._seed_branch(task))
|
|
230
|
+
if repo is None:
|
|
231
|
+
return False
|
|
232
|
+
self._tasks.set_slot(task.id, slot)
|
|
233
|
+
self._tasks.set_wait_reason(task.id, None)
|
|
234
|
+
status = Status.INTEGRATING if task.step is Step.INTEGRATE else Status.RUNNING
|
|
235
|
+
self._tasks.apply_transition(
|
|
236
|
+
task.id, Transition.stay(task, step=task.step, status=status)
|
|
237
|
+
)
|
|
238
|
+
current = self._require_task(task.id, "slot assignment")
|
|
239
|
+
if current.repo is None:
|
|
240
|
+
self._tasks.set_repo(current.id, repo)
|
|
241
|
+
current = self._require_task(task.id, "task-root creation")
|
|
242
|
+
await self._layout.ensure(current)
|
|
243
|
+
current = self._require_task(task.id, "layout creation")
|
|
244
|
+
await self._titles.refresh(current)
|
|
245
|
+
except BaseFetchFailed as error:
|
|
246
|
+
self._logger.warning("dispatch of %s waits for the remote: %s", task.id, error)
|
|
247
|
+
await self._rollback_dispatch(task)
|
|
248
|
+
self._back_off_fetch(task.id, error)
|
|
249
|
+
return False
|
|
250
|
+
except Exception as error:
|
|
251
|
+
self._logger.exception("dispatch setup failed for %s", task.id)
|
|
252
|
+
await self._rollback_dispatch(task)
|
|
253
|
+
failures = self._dispatch_failures.get(task.id, 0) + 1
|
|
254
|
+
self._dispatch_failures[task.id] = failures
|
|
255
|
+
self._events.append(
|
|
256
|
+
EventKind.DISPATCH_FAILED,
|
|
257
|
+
task_id=task.id,
|
|
258
|
+
message=f"{task.id} dispatch failed",
|
|
259
|
+
data={"attempt": failures, "error": str(error)},
|
|
260
|
+
)
|
|
261
|
+
if failures >= MAX_DISPATCH_FAILURES:
|
|
262
|
+
current = self._require_task(task.id, "dispatch failure")
|
|
263
|
+
await self._on_dispatch_failure(current, error)
|
|
264
|
+
self._dispatch_failures.pop(task.id, None)
|
|
265
|
+
return False
|
|
266
|
+
self._dispatch_failures.pop(task.id, None)
|
|
267
|
+
self._fetch_backoffs.pop(task.id, None)
|
|
268
|
+
self._fetch_retry_at.pop(task.id, None)
|
|
269
|
+
self._faults.resolve(FETCH_FAULT_SCOPE, task_id=task.id)
|
|
270
|
+
self._events.append(
|
|
271
|
+
EventKind.TASK_STARTED,
|
|
272
|
+
task_id=current.id,
|
|
273
|
+
message=f"{current.id} started in slot {slot}",
|
|
274
|
+
data={"slot": slot},
|
|
275
|
+
)
|
|
276
|
+
emit_step_started(self._events, current, self._settings)
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
def _awaits_fetch_retry(self, task_id: str) -> bool:
|
|
280
|
+
retry_at = self._fetch_retry_at.get(task_id)
|
|
281
|
+
return retry_at is not None and self._clock.now() < retry_at
|
|
282
|
+
|
|
283
|
+
def _back_off_fetch(self, task_id: str, error: BaseFetchFailed) -> None:
|
|
284
|
+
faults = self._settings.orchestration.faults
|
|
285
|
+
delay = next_backoff(
|
|
286
|
+
self._fetch_backoffs.get(task_id, 0.0),
|
|
287
|
+
initial=faults.backoff_initial_seconds,
|
|
288
|
+
maximum=faults.backoff_max_seconds,
|
|
289
|
+
)
|
|
290
|
+
self._fetch_backoffs[task_id] = delay
|
|
291
|
+
self._fetch_retry_at[task_id] = self._clock.now() + timedelta(seconds=delay)
|
|
292
|
+
self._faults.report(error, scope=FETCH_FAULT_SCOPE, task_id=task_id)
|
|
293
|
+
|
|
294
|
+
def _seed_branch(self, task: Task) -> str | None:
|
|
295
|
+
parent = self._tasks.get(task.parent_id) if task.parent_id is not None else None
|
|
296
|
+
if parent is None or parent.status_reason is not StatusReason.SUPERSEDED:
|
|
297
|
+
return None
|
|
298
|
+
return parent.repo.branch if parent.repo is not None else None
|
|
299
|
+
|
|
300
|
+
async def _rollback_dispatch(self, task: Task) -> None:
|
|
301
|
+
current = self._tasks.get(task.id)
|
|
302
|
+
if current is None:
|
|
303
|
+
return
|
|
304
|
+
if task.window_id is None and current.window_id is not None:
|
|
305
|
+
try:
|
|
306
|
+
await self._layout.close(current)
|
|
307
|
+
except Exception:
|
|
308
|
+
self._logger.exception("dispatch cleanup failed for %s", task.id)
|
|
309
|
+
self._tasks.apply_transition(
|
|
310
|
+
current.id, Transition.stay(current, step=current.step, status=Status.QUEUED)
|
|
311
|
+
)
|
|
312
|
+
self._tasks.set_slot(current.id, None)
|
|
313
|
+
self._tasks.set_wait_reason(current.id, None)
|
|
314
|
+
|
|
315
|
+
def _require_task(self, task_id: str, operation: str) -> Task:
|
|
316
|
+
current = self._tasks.get(task_id)
|
|
317
|
+
if current is None:
|
|
318
|
+
message = f"{task_id} vanished after {operation}"
|
|
319
|
+
raise InvariantError(message)
|
|
320
|
+
return current
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Recreates the project's tmux session after the tmux server or session is lost."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from logging import Logger
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from shikiru.tmux.client import Tmux
|
|
13
|
+
from shikiru.tmux.session import SessionView
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SessionGuard:
|
|
17
|
+
"""Brings back the session and its home window when they have vanished."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
tmux: Tmux,
|
|
23
|
+
view: SessionView,
|
|
24
|
+
session_target: str,
|
|
25
|
+
project_name: str,
|
|
26
|
+
project_root: Path,
|
|
27
|
+
home_command: Sequence[str],
|
|
28
|
+
logger: Logger,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Probe `session_target` through `tmux` and recreate it through `view`."""
|
|
31
|
+
self._tmux = tmux
|
|
32
|
+
self._view = view
|
|
33
|
+
self._session_target = session_target
|
|
34
|
+
self._project_name = project_name
|
|
35
|
+
self._project_root = project_root
|
|
36
|
+
self._home_command = home_command
|
|
37
|
+
self._logger = logger
|
|
38
|
+
|
|
39
|
+
async def ensure(self) -> None:
|
|
40
|
+
"""Recreate the session with its home window when it does not exist."""
|
|
41
|
+
if await self._tmux.target_exists(self._session_target):
|
|
42
|
+
return
|
|
43
|
+
self._logger.warning("tmux session %s is gone; recreating it", self._session_target)
|
|
44
|
+
await self._view.ensure(
|
|
45
|
+
self._project_name, self._project_root, home_command=self._home_command
|
|
46
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Session selection for resumed process-per-turn agent sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shikiru.domain.ladder import pick_rung
|
|
10
|
+
from shikiru.domain.runs import Run, RunStatus
|
|
11
|
+
from shikiru.errors import InvariantError
|
|
12
|
+
from shikiru.paths import turns_dir
|
|
13
|
+
from shikiru.workspace.reports import load_turn_report
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from shikiru.agents.contracts import Agent, Rung
|
|
19
|
+
from shikiru.config.settings import Settings
|
|
20
|
+
from shikiru.domain.tasks import Role, Task
|
|
21
|
+
from shikiru.engine.agents import AgentPool
|
|
22
|
+
from shikiru.store.runs import RunStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SessionSelection:
|
|
27
|
+
"""The rung, resumable session and run row selected for one role turn."""
|
|
28
|
+
|
|
29
|
+
rung: Rung
|
|
30
|
+
session_id: str | None
|
|
31
|
+
handoff: bool
|
|
32
|
+
run: Run
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionManager:
|
|
36
|
+
"""Select and persist the session lineage for the next turn of a task role."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
runs: RunStore,
|
|
42
|
+
agents: AgentPool,
|
|
43
|
+
project_root: Path,
|
|
44
|
+
settings: Settings,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._runs = runs
|
|
47
|
+
self._agents = agents
|
|
48
|
+
self._project_root = project_root
|
|
49
|
+
self._settings = settings
|
|
50
|
+
|
|
51
|
+
def session_for(self, task: Task, role: Role) -> SessionSelection:
|
|
52
|
+
"""Choose a session, resuming it across turns and same-agent rung changes."""
|
|
53
|
+
rung = pick_rung(task, role, self._settings)
|
|
54
|
+
current = self._runs.get_live(task.id, role)
|
|
55
|
+
if current is not None and current.agent == rung.agent:
|
|
56
|
+
self._runs.set_rung(current.id, rung)
|
|
57
|
+
refreshed = self._runs.get_live(task.id, role)
|
|
58
|
+
if refreshed is None:
|
|
59
|
+
message = f"{task.id} {role.value} run disappeared during session selection"
|
|
60
|
+
raise InvariantError(message)
|
|
61
|
+
return SessionSelection(rung, refreshed.session_id, False, refreshed)
|
|
62
|
+
|
|
63
|
+
if current is not None:
|
|
64
|
+
self._runs.close(current.id)
|
|
65
|
+
history_exists = self._has_reports(task.id)
|
|
66
|
+
run = self._runs.start(
|
|
67
|
+
task.id,
|
|
68
|
+
role,
|
|
69
|
+
rung,
|
|
70
|
+
billing_declaration=self._agents.billing_declaration(rung.agent),
|
|
71
|
+
)
|
|
72
|
+
return SessionSelection(rung, None, history_exists, run)
|
|
73
|
+
|
|
74
|
+
def record_session_started(
|
|
75
|
+
self, *, run_id: int, agent: Agent, session_id: str, run_dir: Path, rung: Rung
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Persist the session id and the command an operator can use to resume it."""
|
|
78
|
+
command = shlex.join(agent.interactive_command(session_id, run_dir, rung))
|
|
79
|
+
self._runs.set_session(run_id, session_id, command)
|
|
80
|
+
|
|
81
|
+
def close_task(self, task_id: str) -> None:
|
|
82
|
+
"""Close every role session after its task ends."""
|
|
83
|
+
for run in self._runs.for_task(task_id):
|
|
84
|
+
if run.status is not RunStatus.CLOSED:
|
|
85
|
+
self._runs.close(run.id)
|
|
86
|
+
|
|
87
|
+
def _has_reports(self, task_id: str) -> bool:
|
|
88
|
+
task_turns_dir = turns_dir(self._project_root, task_id)
|
|
89
|
+
if not task_turns_dir.is_dir():
|
|
90
|
+
return False
|
|
91
|
+
return any(
|
|
92
|
+
load_turn_report(turn_dir) is not None
|
|
93
|
+
for turn_dir in task_turns_dir.iterdir()
|
|
94
|
+
if turn_dir.is_dir()
|
|
95
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Runs the engine so that SIGTERM and SIGINT stop it gracefully, within a bound."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import signal
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shikiru.errors import ShikiruError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from shikiru.clock import Clock
|
|
13
|
+
from shikiru.engine.daemon import Engine
|
|
14
|
+
from shikiru.engine.turn_processes import TurnProcesses
|
|
15
|
+
|
|
16
|
+
STOP_SIGNALS = (signal.SIGTERM, signal.SIGINT)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ForcedShutdownError(ShikiruError):
|
|
20
|
+
"""The engine was signalled twice or outlived its shutdown bound and was ended by force."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SignalledEngine:
|
|
24
|
+
"""Run an `Engine` until it stops, turning the first stop signal into `Engine.request_stop`."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
engine: Engine,
|
|
30
|
+
turn_processes: TurnProcesses,
|
|
31
|
+
clock: Clock,
|
|
32
|
+
shutdown_seconds: float,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Bound the graceful shutdown by `shutdown_seconds` on `clock`."""
|
|
35
|
+
self._engine = engine
|
|
36
|
+
self._turn_processes = turn_processes
|
|
37
|
+
self._clock = clock
|
|
38
|
+
self._shutdown_seconds = shutdown_seconds
|
|
39
|
+
self._deadline: asyncio.Task[None] | None = None
|
|
40
|
+
self._forced = False
|
|
41
|
+
|
|
42
|
+
async def run(self) -> None:
|
|
43
|
+
"""Run the engine; a second signal or the bound cancels it and ends every turn process."""
|
|
44
|
+
loop = asyncio.get_running_loop()
|
|
45
|
+
engine = asyncio.create_task(self._engine.run())
|
|
46
|
+
for signum in STOP_SIGNALS:
|
|
47
|
+
loop.add_signal_handler(signum, self._on_signal, engine)
|
|
48
|
+
try:
|
|
49
|
+
await engine
|
|
50
|
+
except asyncio.CancelledError:
|
|
51
|
+
if not self._forced:
|
|
52
|
+
raise
|
|
53
|
+
await self._turn_processes.end_orphans()
|
|
54
|
+
message = f"engine ended by force after {self._shutdown_seconds:g} s or a second signal"
|
|
55
|
+
raise ForcedShutdownError(message) from None
|
|
56
|
+
finally:
|
|
57
|
+
for signum in STOP_SIGNALS:
|
|
58
|
+
loop.remove_signal_handler(signum)
|
|
59
|
+
if self._deadline is not None:
|
|
60
|
+
self._deadline.cancel()
|
|
61
|
+
|
|
62
|
+
def _on_signal(self, engine: asyncio.Task[None]) -> None:
|
|
63
|
+
if self._deadline is not None:
|
|
64
|
+
self._force(engine)
|
|
65
|
+
return
|
|
66
|
+
self._engine.request_stop()
|
|
67
|
+
self._deadline = asyncio.create_task(self._expire(engine))
|
|
68
|
+
|
|
69
|
+
async def _expire(self, engine: asyncio.Task[None]) -> None:
|
|
70
|
+
await self._clock.sleep(self._shutdown_seconds)
|
|
71
|
+
self._force(engine)
|
|
72
|
+
|
|
73
|
+
def _force(self, engine: asyncio.Task[None]) -> None:
|
|
74
|
+
self._forced = True
|
|
75
|
+
engine.cancel()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Runs a step's turn to its transition, the boundary every turn shares."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.recovery import ProtocolError, TurnCounters
|
|
8
|
+
from shikiru.domain.reports import FeedbackAnalysis, ImplementationReport, Verdict
|
|
9
|
+
from shikiru.domain.steps import Transition, next_step
|
|
10
|
+
from shikiru.domain.tasks import EscalationReason, Status, Step
|
|
11
|
+
from shikiru.engine.events import emit_turn_failed
|
|
12
|
+
from shikiru.engine.turn_loop import TurnOverrides, TurnsStopped
|
|
13
|
+
from shikiru.errors import InvariantError, UsageError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from shikiru.config.settings import Settings
|
|
17
|
+
from shikiru.domain.feedback import FeedbackInput
|
|
18
|
+
from shikiru.domain.reports import Report, ReportKind
|
|
19
|
+
from shikiru.domain.steps import Outcome
|
|
20
|
+
from shikiru.domain.tasks import Role, Task
|
|
21
|
+
from shikiru.engine.acceptance import AcceptanceGuard
|
|
22
|
+
from shikiru.engine.escalation_filing import EscalationFiler
|
|
23
|
+
from shikiru.engine.transitions import TransitionWriter
|
|
24
|
+
from shikiru.engine.turn_execution import TurnExecution
|
|
25
|
+
from shikiru.engine.turn_loop import TurnLoop
|
|
26
|
+
from shikiru.prompts.renderer import PromptName
|
|
27
|
+
from shikiru.store.events import EventLog
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TurnStopped(Exception):
|
|
31
|
+
"""Stop a caller's sequence after its task escalates or pauses."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StepRunner:
|
|
35
|
+
"""Runs a step's turn, settles its report and applies the transition the report earns."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
loop: TurnLoop,
|
|
41
|
+
acceptance: AcceptanceGuard,
|
|
42
|
+
escalations: EscalationFiler,
|
|
43
|
+
writer: TransitionWriter,
|
|
44
|
+
events: EventLog,
|
|
45
|
+
settings: Settings,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Run turns through `loop`, guard acceptance, then transition or escalate."""
|
|
48
|
+
self._loop = loop
|
|
49
|
+
self._acceptance = acceptance
|
|
50
|
+
self._escalations = escalations
|
|
51
|
+
self._writer = writer
|
|
52
|
+
self._events = events
|
|
53
|
+
self._settings = settings
|
|
54
|
+
|
|
55
|
+
async def run_step(
|
|
56
|
+
self,
|
|
57
|
+
task: Task,
|
|
58
|
+
step: Step,
|
|
59
|
+
counters: TurnCounters,
|
|
60
|
+
*,
|
|
61
|
+
conflict_context: str | None = None,
|
|
62
|
+
force_new_turn: bool = False,
|
|
63
|
+
) -> tuple[Task, TurnCounters, bool]:
|
|
64
|
+
"""Run one domain step through its turn and recovery loop; report whether it stopped."""
|
|
65
|
+
if force_new_turn:
|
|
66
|
+
self._loop.forget_resume(task.id)
|
|
67
|
+
result = await self._loop.run(
|
|
68
|
+
task, step, counters, TurnOverrides(conflict_context=conflict_context)
|
|
69
|
+
)
|
|
70
|
+
if isinstance(result, TurnsStopped):
|
|
71
|
+
return result.task, result.counters, True
|
|
72
|
+
outcome = await self._acceptance.settle(result.task, result.report, result.execution)
|
|
73
|
+
try:
|
|
74
|
+
current, escalated = await self.advance(result.task, outcome)
|
|
75
|
+
except UsageError as error:
|
|
76
|
+
escalated_task = await self._escalate_scope_change_protocol(
|
|
77
|
+
result.task,
|
|
78
|
+
step,
|
|
79
|
+
outcome,
|
|
80
|
+
error,
|
|
81
|
+
execution=result.execution,
|
|
82
|
+
)
|
|
83
|
+
return escalated_task, TurnCounters(), True
|
|
84
|
+
return current, TurnCounters(), escalated
|
|
85
|
+
|
|
86
|
+
async def run_report_turn(
|
|
87
|
+
self,
|
|
88
|
+
task: Task,
|
|
89
|
+
*,
|
|
90
|
+
role: Role,
|
|
91
|
+
template: PromptName,
|
|
92
|
+
kind: ReportKind,
|
|
93
|
+
feedback: FeedbackInput | None = None,
|
|
94
|
+
) -> tuple[Task, ImplementationReport | Verdict | FeedbackAnalysis]:
|
|
95
|
+
"""Run one integration report through the shared turn and recovery loop."""
|
|
96
|
+
result = await self._loop.run(
|
|
97
|
+
task,
|
|
98
|
+
Step.INTEGRATE,
|
|
99
|
+
TurnCounters(),
|
|
100
|
+
TurnOverrides(role=role, template=template, kind=kind, feedback=feedback),
|
|
101
|
+
)
|
|
102
|
+
if isinstance(result, TurnsStopped):
|
|
103
|
+
raise TurnStopped
|
|
104
|
+
report: Report = result.report
|
|
105
|
+
if not isinstance(report, ImplementationReport | Verdict | FeedbackAnalysis):
|
|
106
|
+
message = f"{result.task.id} integration turn returned an unexpected report"
|
|
107
|
+
raise InvariantError(message)
|
|
108
|
+
return result.task, await self._acceptance.settle(result.task, report, result.execution)
|
|
109
|
+
|
|
110
|
+
async def advance(
|
|
111
|
+
self, task: Task, outcome: Outcome, *, role: Role | None = None
|
|
112
|
+
) -> tuple[Task, bool]:
|
|
113
|
+
"""Apply the transition `outcome` earns `task`; report whether it escalated."""
|
|
114
|
+
transition = next_step(task, outcome, self._settings)
|
|
115
|
+
if transition.status is Status.ESCALATED:
|
|
116
|
+
escalated = await self._escalations.escalate(task, transition, outcome, role=role)
|
|
117
|
+
return escalated, True
|
|
118
|
+
return await self._writer.apply(task, transition, outcome), False
|
|
119
|
+
|
|
120
|
+
async def _escalate_scope_change_protocol(
|
|
121
|
+
self,
|
|
122
|
+
task: Task,
|
|
123
|
+
step: Step,
|
|
124
|
+
outcome: Outcome,
|
|
125
|
+
error: UsageError,
|
|
126
|
+
*,
|
|
127
|
+
execution: TurnExecution,
|
|
128
|
+
) -> Task:
|
|
129
|
+
emit_turn_failed(
|
|
130
|
+
self._events,
|
|
131
|
+
task,
|
|
132
|
+
step,
|
|
133
|
+
ProtocolError(),
|
|
134
|
+
rung=execution.rung,
|
|
135
|
+
run_id=execution.run_id,
|
|
136
|
+
turn=execution.turn_dir.name,
|
|
137
|
+
role=execution.role,
|
|
138
|
+
)
|
|
139
|
+
transition = Transition.escalate(task, EscalationReason.AGENT_PROTOCOL, step=step)
|
|
140
|
+
return await self._escalations.escalate(task, transition, outcome, question=str(error))
|