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,101 @@
|
|
|
1
|
+
"""Supervise concurrent task flows for the engine daemon."""
|
|
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.tasks import Status, StatusReason
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from logging import Logger
|
|
13
|
+
|
|
14
|
+
from shikiru.engine.flow import TaskFlow
|
|
15
|
+
from shikiru.engine.lifecycle import TaskFinisher
|
|
16
|
+
from shikiru.store.tasks import TaskStore
|
|
17
|
+
|
|
18
|
+
_IN_FLIGHT = (Status.RUNNING, Status.INTEGRATING)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskSupervisor:
|
|
22
|
+
"""Start, interrupt and reap the task flows owned by one engine daemon."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, tasks: TaskStore, flow: TaskFlow, finisher: TaskFinisher) -> None:
|
|
25
|
+
self._tasks = tasks
|
|
26
|
+
self._flow = flow
|
|
27
|
+
self._finisher = finisher
|
|
28
|
+
self._stop = asyncio.Event()
|
|
29
|
+
self._flow_tasks: dict[str, asyncio.Task[None]] = {}
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def stop_requested(self) -> bool:
|
|
33
|
+
return self._stop.is_set()
|
|
34
|
+
|
|
35
|
+
def request_stop(self) -> None:
|
|
36
|
+
"""Ask the daemon command loop to stop after acknowledging the current command."""
|
|
37
|
+
self._stop.set()
|
|
38
|
+
|
|
39
|
+
async def start_task(self, task_id: str) -> None:
|
|
40
|
+
"""Schedule a task flow unless one is still live; `ensure_flows` covers a finishing one."""
|
|
41
|
+
self._start(task_id, resumed=False)
|
|
42
|
+
|
|
43
|
+
async def resume_task(self, task_id: str) -> None:
|
|
44
|
+
"""Schedule the flow of a task whose step an engine restart interrupted."""
|
|
45
|
+
self._start(task_id, resumed=True)
|
|
46
|
+
|
|
47
|
+
async def ensure_flows(self) -> None:
|
|
48
|
+
"""Start a resumed flow for each unheld running or integrating task without one."""
|
|
49
|
+
for task in self._tasks.list_active():
|
|
50
|
+
if task.status in _IN_FLIGHT and not task.held:
|
|
51
|
+
self._start(task.id, resumed=True)
|
|
52
|
+
|
|
53
|
+
def _start(self, task_id: str, *, resumed: bool) -> None:
|
|
54
|
+
existing = self._flow_tasks.get(task_id)
|
|
55
|
+
if existing is not None and not existing.done():
|
|
56
|
+
return
|
|
57
|
+
self._flow_tasks[task_id] = asyncio.create_task(self._run_flow(task_id, resumed=resumed))
|
|
58
|
+
|
|
59
|
+
async def cancel_task(self, task_id: str) -> None:
|
|
60
|
+
"""Interrupt and await one supervised flow coroutine, if present."""
|
|
61
|
+
current = self._flow_tasks.get(task_id)
|
|
62
|
+
if current is None:
|
|
63
|
+
return
|
|
64
|
+
current.cancel()
|
|
65
|
+
with suppress(asyncio.CancelledError):
|
|
66
|
+
await current
|
|
67
|
+
self._flow_tasks.pop(task_id, None)
|
|
68
|
+
|
|
69
|
+
async def supervise(self, logger: Logger) -> None:
|
|
70
|
+
"""Log task flow failures and remove completed flows from the active set."""
|
|
71
|
+
for task_id, flow_task in tuple(self._flow_tasks.items()):
|
|
72
|
+
if not flow_task.done():
|
|
73
|
+
continue
|
|
74
|
+
self._flow_tasks.pop(task_id, None)
|
|
75
|
+
if flow_task.cancelled():
|
|
76
|
+
continue
|
|
77
|
+
error = flow_task.exception()
|
|
78
|
+
if error is not None:
|
|
79
|
+
logger.error(
|
|
80
|
+
"task flow %s failed",
|
|
81
|
+
task_id,
|
|
82
|
+
exc_info=(type(error), error, error.__traceback__),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def shutdown(self) -> None:
|
|
86
|
+
"""Cancel and await all task flows before the daemon closes its resources."""
|
|
87
|
+
for task in tuple(self._flow_tasks.values()):
|
|
88
|
+
task.cancel()
|
|
89
|
+
if self._flow_tasks:
|
|
90
|
+
await asyncio.gather(*self._flow_tasks.values(), return_exceptions=True)
|
|
91
|
+
self._flow_tasks.clear()
|
|
92
|
+
|
|
93
|
+
async def _run_flow(self, task_id: str, *, resumed: bool) -> None:
|
|
94
|
+
try:
|
|
95
|
+
await self._flow.run(task_id, resumed=resumed)
|
|
96
|
+
finally:
|
|
97
|
+
current = self._tasks.get(task_id)
|
|
98
|
+
if current is not None and (
|
|
99
|
+
current.status is Status.DONE or current.status_reason is StatusReason.SUPERSEDED
|
|
100
|
+
):
|
|
101
|
+
await self._finisher.finish_task(current)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Operator take over and hand back of one task role's session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
7
|
+
|
|
8
|
+
from shikiru.agents.contracts import Rung
|
|
9
|
+
from shikiru.domain.commands import COMMAND_ID_KEY
|
|
10
|
+
from shikiru.domain.events import EventKind
|
|
11
|
+
from shikiru.domain.ladder import pick_rung
|
|
12
|
+
from shikiru.domain.tasks import Hold, Role, Status, Task
|
|
13
|
+
from shikiru.engine.turns import session_environment
|
|
14
|
+
from shikiru.errors import PROCESS_FAILURES, CommandRejectedError
|
|
15
|
+
from shikiru.paths import run_dir, task_root
|
|
16
|
+
from shikiru.tmux.client import FORMAT_PANE_PID
|
|
17
|
+
from shikiru.workspace.messages import command_message_key
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
from contextlib import AbstractContextManager
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from shikiru.config.settings import Settings
|
|
25
|
+
from shikiru.domain.runs import Run
|
|
26
|
+
from shikiru.engine.agents import AgentPool
|
|
27
|
+
from shikiru.engine.layout_guard import LayoutGuard
|
|
28
|
+
from shikiru.store.events import EventLog
|
|
29
|
+
from shikiru.store.runs import RunStore
|
|
30
|
+
from shikiru.store.tasks import TaskStore
|
|
31
|
+
from shikiru.tmux.client import Tmux
|
|
32
|
+
from shikiru.workspace.messages import OperatorMessages
|
|
33
|
+
|
|
34
|
+
HAND_BACK_MESSAGE = (
|
|
35
|
+
"The Operator worked in this session since your last turn; take their changes into account."
|
|
36
|
+
)
|
|
37
|
+
_ACTIVE_STATUSES = (Status.RUNNING, Status.INTEGRATING)
|
|
38
|
+
TAKEOVER_PANE_OPTION = "@shk_takeover"
|
|
39
|
+
_FORMAT_TAKEOVER = f"#{{{TAKEOVER_PANE_OPTION}}}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Takeover:
|
|
43
|
+
"""Moves a role's session between the engine's headless turns and the Operator's pane."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
tmux: Tmux,
|
|
49
|
+
layout: LayoutGuard,
|
|
50
|
+
agents: AgentPool,
|
|
51
|
+
tasks: TaskStore,
|
|
52
|
+
runs: RunStore,
|
|
53
|
+
messages: OperatorMessages,
|
|
54
|
+
events: EventLog,
|
|
55
|
+
project_root: Path,
|
|
56
|
+
project_name: str,
|
|
57
|
+
settings: Settings,
|
|
58
|
+
cancel_task: Callable[[str], Awaitable[None]],
|
|
59
|
+
start_task: Callable[[str], Awaitable[None]],
|
|
60
|
+
refresh_titles: Callable[[Task], Awaitable[None]],
|
|
61
|
+
atomically: Callable[[], AbstractContextManager[object]],
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Drive panes through `tmux`/`layout` and task flows through the two callbacks."""
|
|
64
|
+
self._tmux = tmux
|
|
65
|
+
self._layout = layout
|
|
66
|
+
self._agents = agents
|
|
67
|
+
self._tasks = tasks
|
|
68
|
+
self._runs = runs
|
|
69
|
+
self._messages = messages
|
|
70
|
+
self._events = events
|
|
71
|
+
self._project_root = project_root
|
|
72
|
+
self._project_name = project_name
|
|
73
|
+
self._settings = settings
|
|
74
|
+
self._cancel_task = cancel_task
|
|
75
|
+
self._start_task = start_task
|
|
76
|
+
self._refresh_titles = refresh_titles
|
|
77
|
+
self._atomically = atomically
|
|
78
|
+
|
|
79
|
+
async def take_over(self, task: Task, role: Role, command_id: int) -> None:
|
|
80
|
+
"""Stop `task`'s turn, hold it and run `role`'s session interactively in its pane.
|
|
81
|
+
|
|
82
|
+
The run mark, hold and event (naming `command_id`) commit together before the pane is
|
|
83
|
+
respawned; a replay after that commit skips them and only respawns the pane, which is
|
|
84
|
+
repeatable, while the role is still taken over.
|
|
85
|
+
"""
|
|
86
|
+
if not self._events.command_applied(EventKind.TAKEN_OVER, command_id):
|
|
87
|
+
if self._runs.get_taken_over(task.id, role) is not None:
|
|
88
|
+
message = f"{task.id} {role.value} is already taken over"
|
|
89
|
+
raise CommandRejectedError(message)
|
|
90
|
+
run = self._runs.get_live(task.id, role)
|
|
91
|
+
if run is None or run.session_id is None:
|
|
92
|
+
message = f"{task.id} has no {role.value} session to take over"
|
|
93
|
+
raise CommandRejectedError(message)
|
|
94
|
+
_role_pane(task, role)
|
|
95
|
+
if task.status in _ACTIVE_STATUSES:
|
|
96
|
+
await self._cancel_task(task.id)
|
|
97
|
+
with self._atomically():
|
|
98
|
+
self._runs.mark_taken_over(run.id)
|
|
99
|
+
self._tasks.set_holds(task.id, self._current(task.id).holds | {Hold.INTERVENTION})
|
|
100
|
+
self._events.append(
|
|
101
|
+
EventKind.TAKEN_OVER,
|
|
102
|
+
task_id=task.id,
|
|
103
|
+
message=f"{task.id} {role.value} taken over",
|
|
104
|
+
data={
|
|
105
|
+
"role": role.value,
|
|
106
|
+
"session_id": run.session_id,
|
|
107
|
+
COMMAND_ID_KEY: command_id,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
owned = self._runs.get_taken_over(task.id, role)
|
|
111
|
+
if owned is None or owned.session_id is None:
|
|
112
|
+
message = f"{task.id} {role.value} take over did not hold"
|
|
113
|
+
raise CommandRejectedError(message)
|
|
114
|
+
try:
|
|
115
|
+
await self._ensure_interactive(
|
|
116
|
+
self._current(task.id), role, owned, owned.session_id, command_id
|
|
117
|
+
)
|
|
118
|
+
except PROCESS_FAILURES as error:
|
|
119
|
+
await self._release_unstarted(task, role, owned.id, error)
|
|
120
|
+
await self._refresh_titles(self._current(task.id))
|
|
121
|
+
|
|
122
|
+
async def _ensure_interactive(
|
|
123
|
+
self, task: Task, role: Role, run: Run, session_id: str, command_id: int
|
|
124
|
+
) -> None:
|
|
125
|
+
pane = _role_pane(task, role)
|
|
126
|
+
if await self._interactive_is_live(pane, command_id):
|
|
127
|
+
return
|
|
128
|
+
configured_rung = pick_rung(task, role, self._settings)
|
|
129
|
+
rung = (
|
|
130
|
+
configured_rung
|
|
131
|
+
if configured_rung.agent == run.agent
|
|
132
|
+
else Rung(agent=run.agent, model=run.model, effort=run.effort)
|
|
133
|
+
)
|
|
134
|
+
root = task_root(self._project_root, task.id)
|
|
135
|
+
task_run_dir = run_dir(self._project_root, task.id)
|
|
136
|
+
argv = self._agents.get(run.agent).interactive_command(session_id, task_run_dir, rung)
|
|
137
|
+
env = session_environment(task, role, task_run_dir, self._project_name)
|
|
138
|
+
await self._tmux.respawn_pane(pane, argv, env=env, cwd=root)
|
|
139
|
+
pid = await self._tmux.display(pane, FORMAT_PANE_PID)
|
|
140
|
+
with self._atomically():
|
|
141
|
+
if (run.model, run.effort) != (rung.model, rung.effort):
|
|
142
|
+
self._runs.set_rung(run.id, rung)
|
|
143
|
+
self._runs.set_session(run.id, session_id, shlex.join(argv))
|
|
144
|
+
await self._tmux.set_pane_option(pane, TAKEOVER_PANE_OPTION, _live_marker(command_id, pid))
|
|
145
|
+
|
|
146
|
+
async def _interactive_is_live(self, pane: str, command_id: int) -> bool:
|
|
147
|
+
marker = await self._tmux.display(pane, _FORMAT_TAKEOVER)
|
|
148
|
+
pid = await self._tmux.display(pane, FORMAT_PANE_PID)
|
|
149
|
+
return marker == _live_marker(command_id, pid) and not await self._tmux.is_pane_dead(pane)
|
|
150
|
+
|
|
151
|
+
async def _release_unstarted(
|
|
152
|
+
self, task: Task, role: Role, run_id: int, error: Exception
|
|
153
|
+
) -> NoReturn:
|
|
154
|
+
with self._atomically():
|
|
155
|
+
self._runs.mark_live(run_id)
|
|
156
|
+
if not self._runs.has_taken_over(task.id):
|
|
157
|
+
holds = self._current(task.id).holds - {Hold.INTERVENTION}
|
|
158
|
+
self._tasks.set_holds(task.id, holds)
|
|
159
|
+
self._events.append(
|
|
160
|
+
EventKind.TAKEOVER_LOST,
|
|
161
|
+
task_id=task.id,
|
|
162
|
+
message=f"{task.id} {role.value} take over released: its pane could not be started",
|
|
163
|
+
data={"role": role.value},
|
|
164
|
+
)
|
|
165
|
+
current = self._current(task.id)
|
|
166
|
+
if current.status in _ACTIVE_STATUSES and not current.held:
|
|
167
|
+
await self._start_task(task.id)
|
|
168
|
+
await self._refresh_titles(current)
|
|
169
|
+
message = (
|
|
170
|
+
f"{task.id} {role.value} was not taken over: its pane could not be started ({error}); "
|
|
171
|
+
"nothing is held and the task continues, take over again to retry"
|
|
172
|
+
)
|
|
173
|
+
raise CommandRejectedError(message) from error
|
|
174
|
+
|
|
175
|
+
async def hand_back(self, task: Task, role: Role, command_id: int) -> None:
|
|
176
|
+
"""Return `role`'s taken-over session to the engine and re-run the current step.
|
|
177
|
+
|
|
178
|
+
The run, hold and event changes commit together, and the event names `command_id`; a
|
|
179
|
+
replay after that commit skips them and only repeats the follow-up.
|
|
180
|
+
"""
|
|
181
|
+
if not self._events.command_applied(EventKind.HANDED_BACK, command_id):
|
|
182
|
+
run = self._runs.get_taken_over(task.id, role)
|
|
183
|
+
if run is None:
|
|
184
|
+
message = f"{task.id} {role.value} is not taken over"
|
|
185
|
+
raise CommandRejectedError(message)
|
|
186
|
+
await self._layout.start_view(task.id, role, _role_pane(task, role))
|
|
187
|
+
path = self._messages.queue(
|
|
188
|
+
task.id, role, HAND_BACK_MESSAGE, command_message_key(command_id)
|
|
189
|
+
)
|
|
190
|
+
with self._atomically():
|
|
191
|
+
self._runs.mark_live(run.id)
|
|
192
|
+
if not self._runs.has_taken_over(task.id):
|
|
193
|
+
holds = self._current(task.id).holds - {Hold.INTERVENTION}
|
|
194
|
+
self._tasks.set_holds(task.id, holds)
|
|
195
|
+
self._events.append(
|
|
196
|
+
EventKind.HANDED_BACK,
|
|
197
|
+
task_id=task.id,
|
|
198
|
+
message=f"{task.id} {role.value} handed back",
|
|
199
|
+
data={"role": role.value, "path": str(path), COMMAND_ID_KEY: command_id},
|
|
200
|
+
)
|
|
201
|
+
current = self._current(task.id)
|
|
202
|
+
if current.status in _ACTIVE_STATUSES and not current.held:
|
|
203
|
+
await self._start_task(task.id)
|
|
204
|
+
await self._refresh_titles(self._current(task.id))
|
|
205
|
+
|
|
206
|
+
def _current(self, task_id: str) -> Task:
|
|
207
|
+
task = self._tasks.get(task_id)
|
|
208
|
+
if task is None:
|
|
209
|
+
message = f"task {task_id} disappeared during take over"
|
|
210
|
+
raise CommandRejectedError(message)
|
|
211
|
+
return task
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _live_marker(command_id: int, pid: str) -> str:
|
|
215
|
+
return f"{command_id}:{pid}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _role_pane(task: Task, role: Role) -> str:
|
|
219
|
+
pane_id = task.panes.get(role)
|
|
220
|
+
if pane_id is None:
|
|
221
|
+
message = f"{task.id} has no {role.value} pane"
|
|
222
|
+
raise CommandRejectedError(message)
|
|
223
|
+
return pane_id
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Git facts of a task's worktree, shared by prompt, evidence and acceptance code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.errors import InvariantError
|
|
8
|
+
from shikiru.paths import task_root
|
|
9
|
+
from shikiru.workspace.facts import render_facts
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from shikiru.config.settings import Settings
|
|
15
|
+
from shikiru.domain.tasks import RepoState, Task
|
|
16
|
+
from shikiru.workspace.facts import GitFacts, WorkspaceFacts
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def require_repo(task: Task) -> RepoState:
|
|
20
|
+
"""Return the task's repo state or raise when its task root does not exist yet."""
|
|
21
|
+
if task.repo is None:
|
|
22
|
+
message = f"{task.id} has no task root yet"
|
|
23
|
+
raise InvariantError(message)
|
|
24
|
+
return task.repo
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TaskFacts:
|
|
28
|
+
"""Collects and renders the facts of one task's worktree."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, git_facts: GitFacts, project_root: Path, settings: Settings) -> None:
|
|
31
|
+
"""Collect facts from `git_facts` for worktrees under `project_root`."""
|
|
32
|
+
self._git_facts = git_facts
|
|
33
|
+
self._project_root = project_root
|
|
34
|
+
self._settings = settings
|
|
35
|
+
|
|
36
|
+
async def collect(self, task: Task) -> WorkspaceFacts:
|
|
37
|
+
"""Collect the raw facts of `task`'s worktree."""
|
|
38
|
+
return await self._git_facts.collect(
|
|
39
|
+
task_root(self._project_root, task.id),
|
|
40
|
+
require_repo(task),
|
|
41
|
+
self._settings.project.trackers,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def text(self, task: Task) -> str:
|
|
45
|
+
"""Render the facts of `task`'s worktree as prompt markdown."""
|
|
46
|
+
return render_facts(await self.collect(task))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The fault barrier around one engine tick or command pass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.errors import ShikiruError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from logging import Logger
|
|
14
|
+
|
|
15
|
+
from shikiru.clock import Clock
|
|
16
|
+
from shikiru.config.settings import FaultsConfig
|
|
17
|
+
from shikiru.engine.fault_reporter import FaultReporter
|
|
18
|
+
|
|
19
|
+
_BACKOFF_FACTOR = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EngineFaultLimitError(ShikiruError):
|
|
23
|
+
"""Unexpected (programming) faults repeated on every tick up to the configured limit."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TickGuard:
|
|
27
|
+
"""Runs a tick, retrying it with bounded backoff after a fault instead of ending the engine.
|
|
28
|
+
|
|
29
|
+
Recoverable faults (`fault_reporter.RECOVERABLE_FAULTS`) are retried without limit. Any other
|
|
30
|
+
exception is a programming error: it is recorded like the rest, and `unexpected_fault_limit`
|
|
31
|
+
of them in a row end the engine with `EngineFaultLimitError`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
reporter: FaultReporter,
|
|
38
|
+
clock: Clock,
|
|
39
|
+
settings: FaultsConfig,
|
|
40
|
+
logger: Logger,
|
|
41
|
+
scope: str,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Record faults through `reporter` under `scope` and pace retries with `clock`."""
|
|
44
|
+
self._reporter = reporter
|
|
45
|
+
self._clock = clock
|
|
46
|
+
self._settings = settings
|
|
47
|
+
self._logger = logger
|
|
48
|
+
self._scope = scope
|
|
49
|
+
self._streak = 0
|
|
50
|
+
self._unexpected = 0
|
|
51
|
+
self._backoff = 0.0
|
|
52
|
+
self._retry_at: datetime | None = None
|
|
53
|
+
|
|
54
|
+
async def attempt[T](self, tick: Callable[[], Awaitable[T]]) -> T | None:
|
|
55
|
+
"""Run `tick` unless a backoff is pending, absorbing its fault.
|
|
56
|
+
|
|
57
|
+
Returns what `tick` returned, or `None` when it was skipped or failed.
|
|
58
|
+
"""
|
|
59
|
+
if self._retry_at is not None and self._clock.now() < self._retry_at:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
result = await tick()
|
|
63
|
+
except Exception as error:
|
|
64
|
+
self._logger.exception("%s failed", self._scope)
|
|
65
|
+
self._record(error)
|
|
66
|
+
return None
|
|
67
|
+
self._recover()
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def _record(self, error: Exception) -> None:
|
|
71
|
+
recoverable = self._reporter.report(error, scope=self._scope)
|
|
72
|
+
self._streak += 1
|
|
73
|
+
self._unexpected = 0 if recoverable else self._unexpected + 1
|
|
74
|
+
grown = (
|
|
75
|
+
self._settings.backoff_initial_seconds
|
|
76
|
+
if self._streak == 1
|
|
77
|
+
else self._backoff * _BACKOFF_FACTOR
|
|
78
|
+
)
|
|
79
|
+
self._backoff = min(grown, self._settings.backoff_max_seconds)
|
|
80
|
+
self._retry_at = self._clock.now() + timedelta(seconds=self._backoff)
|
|
81
|
+
self._logger.warning(
|
|
82
|
+
"%s attempt %d failed; retry in %.0fs", self._scope, self._streak, self._backoff
|
|
83
|
+
)
|
|
84
|
+
if self._unexpected >= self._settings.unexpected_fault_limit:
|
|
85
|
+
kind = type(error).__name__
|
|
86
|
+
message = f"{self._unexpected} consecutive unexpected faults, last {kind}: {error}"
|
|
87
|
+
raise EngineFaultLimitError(message) from error
|
|
88
|
+
|
|
89
|
+
def _recover(self) -> None:
|
|
90
|
+
self._reporter.resolve(self._scope)
|
|
91
|
+
if self._streak:
|
|
92
|
+
self._logger.info("%s recovered after %d failed attempts", self._scope, self._streak)
|
|
93
|
+
self._streak = 0
|
|
94
|
+
self._unexpected = 0
|
|
95
|
+
self._retry_at = None
|