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,60 @@
|
|
|
1
|
+
"""Shared terminal task cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.tasks import Status
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from shikiru.domain.tasks import Task
|
|
13
|
+
from shikiru.engine.feedback_audit import FeedbackWorkAudit
|
|
14
|
+
from shikiru.engine.layout_guard import LayoutGuard
|
|
15
|
+
from shikiru.engine.pull_request_closing import PullRequestClosing
|
|
16
|
+
from shikiru.engine.sessions import SessionManager
|
|
17
|
+
from shikiru.workspace.roots import TaskRootBuilder
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TaskFinisher:
|
|
21
|
+
"""Close task sessions and window, queue a cancelled task's PR close, remove its worktree."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
sessions: SessionManager,
|
|
26
|
+
layout: LayoutGuard,
|
|
27
|
+
roots: TaskRootBuilder,
|
|
28
|
+
pull_requests: PullRequestClosing,
|
|
29
|
+
audit: FeedbackWorkAudit,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._sessions = sessions
|
|
32
|
+
self._layout = layout
|
|
33
|
+
self._roots = roots
|
|
34
|
+
self._pull_requests = pull_requests
|
|
35
|
+
self._audit = audit
|
|
36
|
+
self._finished_tasks: set[str] = set()
|
|
37
|
+
|
|
38
|
+
async def finish_task(self, task: Task) -> Path | None:
|
|
39
|
+
"""Release task resources after it reaches a terminal lifecycle state.
|
|
40
|
+
|
|
41
|
+
A cancelled task's open pull request is queued for closing off the calling loop, never
|
|
42
|
+
awaited here. The worktree goes last, so a task root left behind marks unfinished
|
|
43
|
+
cleanup.
|
|
44
|
+
A done task whose branch holds feedback commits the pull request never received keeps
|
|
45
|
+
its worktree and branch; the merged evidence still completes it.
|
|
46
|
+
Returns the task root when unintegrated work kept it in place.
|
|
47
|
+
"""
|
|
48
|
+
if task.id in self._finished_tasks:
|
|
49
|
+
return None
|
|
50
|
+
self._sessions.close_task(task.id)
|
|
51
|
+
await self._layout.close(task)
|
|
52
|
+
if task.status is Status.CANCELLED:
|
|
53
|
+
self._pull_requests.request(task.id)
|
|
54
|
+
integrated = task.status is Status.DONE
|
|
55
|
+
keep_work = integrated and await self._audit.holds_unpublished_work(task)
|
|
56
|
+
kept = await self._roots.cleanup(
|
|
57
|
+
task.id, task.repo, integrated=integrated and not keep_work, keep_work=keep_work
|
|
58
|
+
)
|
|
59
|
+
self._finished_tasks.add(task.id)
|
|
60
|
+
return kept
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Consumes one headless turn stream and records its facts and live view."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from functools import partial
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from shikiru.agents.contracts import (
|
|
14
|
+
AgentSignal,
|
|
15
|
+
Compacted,
|
|
16
|
+
Message,
|
|
17
|
+
QuotaReported,
|
|
18
|
+
SessionStarted,
|
|
19
|
+
ToolFinished,
|
|
20
|
+
ToolStarted,
|
|
21
|
+
Turn,
|
|
22
|
+
TurnEnded,
|
|
23
|
+
TurnFailed,
|
|
24
|
+
UsageReported,
|
|
25
|
+
)
|
|
26
|
+
from shikiru.domain.activity import is_stalled, seconds_until_stall
|
|
27
|
+
from shikiru.domain.events import EventKind
|
|
28
|
+
from shikiru.domain.recovery import ProtocolError, Stalled
|
|
29
|
+
from shikiru.private import append_private_text, make_private_dir
|
|
30
|
+
from shikiru.ui.glyphs import GLYPH_STALLED, ROLE_GLYPHS
|
|
31
|
+
from shikiru.ui.tokens import HOLD_COLOR, ROLE_COLORS
|
|
32
|
+
from shikiru.view_log import (
|
|
33
|
+
VIEW_RUNG_KEY,
|
|
34
|
+
VIEW_SIGNAL_KEY,
|
|
35
|
+
VIEW_TIME_KEY,
|
|
36
|
+
VIEW_TURN_KEY,
|
|
37
|
+
view_log_path,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from shikiru.agents.contracts import Agent, Rung
|
|
45
|
+
from shikiru.clock import Clock
|
|
46
|
+
from shikiru.config.settings import Settings
|
|
47
|
+
from shikiru.domain.tasks import Role, Task
|
|
48
|
+
from shikiru.engine.quota_reconciler import QuotaReconciler
|
|
49
|
+
from shikiru.engine.sessions import SessionManager
|
|
50
|
+
from shikiru.engine.turn_processes import TurnProcesses
|
|
51
|
+
from shikiru.store.events import EventLog
|
|
52
|
+
from shikiru.store.runs import RunStore
|
|
53
|
+
from shikiru.tmux.decorator import PaneDecorator
|
|
54
|
+
|
|
55
|
+
_LINE_ENDING = "\n"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TurnMonitor:
|
|
59
|
+
"""Read a turn's signals once, persist run facts and stop a stalled process."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
sessions: SessionManager,
|
|
65
|
+
runs: RunStore,
|
|
66
|
+
quotas: QuotaReconciler,
|
|
67
|
+
events: EventLog,
|
|
68
|
+
clock: Clock,
|
|
69
|
+
settings: Settings,
|
|
70
|
+
decorator: PaneDecorator,
|
|
71
|
+
refresh_titles: Callable[[Task], Awaitable[None]],
|
|
72
|
+
processes: TurnProcesses,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._sessions = sessions
|
|
75
|
+
self._runs = runs
|
|
76
|
+
self._quotas = quotas
|
|
77
|
+
self._events = events
|
|
78
|
+
self._clock = clock
|
|
79
|
+
self._settings = settings
|
|
80
|
+
self._decorator = decorator
|
|
81
|
+
self._refresh_titles = refresh_titles
|
|
82
|
+
self._processes = processes
|
|
83
|
+
|
|
84
|
+
def process_recorder(self, run_dir: Path, role: Role) -> Callable[[int], None]:
|
|
85
|
+
"""Return the callback recording each process of `role`'s turn as it is spawned."""
|
|
86
|
+
return partial(self._processes.record, run_dir, role)
|
|
87
|
+
|
|
88
|
+
async def run(
|
|
89
|
+
self,
|
|
90
|
+
turn: Turn,
|
|
91
|
+
*,
|
|
92
|
+
agent: Agent,
|
|
93
|
+
task: Task,
|
|
94
|
+
role: Role,
|
|
95
|
+
rung: Rung,
|
|
96
|
+
run_id: int,
|
|
97
|
+
turn_dir: Path,
|
|
98
|
+
run_dir: Path,
|
|
99
|
+
) -> TurnEnded | TurnFailed | Stalled | ProtocolError:
|
|
100
|
+
"""Consume `turn` through its terminal signal, or stop it when it stalls."""
|
|
101
|
+
iterator = turn.signals()
|
|
102
|
+
last_signal_at = self._clock.now()
|
|
103
|
+
tool_running = False
|
|
104
|
+
pending_signal = asyncio.create_task(_next_signal(iterator))
|
|
105
|
+
try:
|
|
106
|
+
await self._decorator.set_pane_state(
|
|
107
|
+
task.panes[role], color=ROLE_COLORS[role], glyph=ROLE_GLYPHS[role]
|
|
108
|
+
)
|
|
109
|
+
while True:
|
|
110
|
+
now = self._clock.now()
|
|
111
|
+
timeouts = self._settings.orchestration.timeouts
|
|
112
|
+
if is_stalled(last_signal_at, tool_running, now, timeouts):
|
|
113
|
+
await turn.stop()
|
|
114
|
+
tool_running, pending_signal = await self._drain_stopped_turn(
|
|
115
|
+
pending_signal,
|
|
116
|
+
iterator,
|
|
117
|
+
turn_dir=turn_dir,
|
|
118
|
+
run_dir=run_dir,
|
|
119
|
+
agent=agent,
|
|
120
|
+
task=task,
|
|
121
|
+
role=role,
|
|
122
|
+
rung=rung,
|
|
123
|
+
run_id=run_id,
|
|
124
|
+
tool_running=tool_running,
|
|
125
|
+
)
|
|
126
|
+
await self._decorator.set_pane_state(
|
|
127
|
+
task.panes[role], color=HOLD_COLOR, glyph=GLYPH_STALLED
|
|
128
|
+
)
|
|
129
|
+
return Stalled()
|
|
130
|
+
poll_timeout = min(
|
|
131
|
+
self._settings.orchestration.poll_seconds,
|
|
132
|
+
seconds_until_stall(last_signal_at, tool_running, now, timeouts),
|
|
133
|
+
)
|
|
134
|
+
signal_ready = await self._wait_for_signal(pending_signal, poll_timeout)
|
|
135
|
+
if not signal_ready:
|
|
136
|
+
continue
|
|
137
|
+
signal = pending_signal.result()
|
|
138
|
+
pending_signal = asyncio.create_task(_next_signal(iterator))
|
|
139
|
+
last_signal_at = self._clock.now()
|
|
140
|
+
self._append_view(run_dir, turn_dir, role, rung, signal)
|
|
141
|
+
tool_running, terminal = await self._record_signal(
|
|
142
|
+
signal,
|
|
143
|
+
agent=agent,
|
|
144
|
+
task=task,
|
|
145
|
+
role=role,
|
|
146
|
+
rung=rung,
|
|
147
|
+
run_id=run_id,
|
|
148
|
+
run_dir=run_dir,
|
|
149
|
+
tool_running=tool_running,
|
|
150
|
+
)
|
|
151
|
+
if terminal is not None:
|
|
152
|
+
return terminal
|
|
153
|
+
except StopAsyncIteration:
|
|
154
|
+
return ProtocolError()
|
|
155
|
+
except asyncio.CancelledError:
|
|
156
|
+
await turn.stop()
|
|
157
|
+
_, pending_signal = await asyncio.shield(
|
|
158
|
+
self._drain_stopped_turn(
|
|
159
|
+
pending_signal,
|
|
160
|
+
iterator,
|
|
161
|
+
turn_dir=turn_dir,
|
|
162
|
+
run_dir=run_dir,
|
|
163
|
+
agent=agent,
|
|
164
|
+
task=task,
|
|
165
|
+
role=role,
|
|
166
|
+
rung=rung,
|
|
167
|
+
run_id=run_id,
|
|
168
|
+
tool_running=tool_running,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
raise
|
|
172
|
+
except BaseException:
|
|
173
|
+
await turn.stop()
|
|
174
|
+
raise
|
|
175
|
+
finally:
|
|
176
|
+
if not pending_signal.done():
|
|
177
|
+
pending_signal.cancel()
|
|
178
|
+
await asyncio.gather(pending_signal, return_exceptions=True)
|
|
179
|
+
self._processes.clear(run_dir, role)
|
|
180
|
+
|
|
181
|
+
async def _drain_stopped_turn(
|
|
182
|
+
self,
|
|
183
|
+
pending_signal: asyncio.Task[AgentSignal],
|
|
184
|
+
iterator: AsyncIterator[AgentSignal],
|
|
185
|
+
*,
|
|
186
|
+
turn_dir: Path,
|
|
187
|
+
run_dir: Path,
|
|
188
|
+
agent: Agent,
|
|
189
|
+
task: Task,
|
|
190
|
+
role: Role,
|
|
191
|
+
rung: Rung,
|
|
192
|
+
run_id: int,
|
|
193
|
+
tool_running: bool,
|
|
194
|
+
) -> tuple[bool, asyncio.Task[AgentSignal]]:
|
|
195
|
+
try:
|
|
196
|
+
async with asyncio.timeout(self._settings.orchestration.shutdown_seconds):
|
|
197
|
+
while True:
|
|
198
|
+
try:
|
|
199
|
+
signal = await pending_signal
|
|
200
|
+
except StopAsyncIteration:
|
|
201
|
+
return tool_running, pending_signal
|
|
202
|
+
self._append_view(run_dir, turn_dir, role, rung, signal)
|
|
203
|
+
tool_running, terminal = await self._record_signal(
|
|
204
|
+
signal,
|
|
205
|
+
agent=agent,
|
|
206
|
+
task=task,
|
|
207
|
+
role=role,
|
|
208
|
+
rung=rung,
|
|
209
|
+
run_id=run_id,
|
|
210
|
+
run_dir=run_dir,
|
|
211
|
+
tool_running=tool_running,
|
|
212
|
+
)
|
|
213
|
+
if terminal is not None:
|
|
214
|
+
return tool_running, pending_signal
|
|
215
|
+
pending_signal = asyncio.create_task(_next_signal(iterator))
|
|
216
|
+
except TimeoutError:
|
|
217
|
+
if not pending_signal.done():
|
|
218
|
+
pending_signal.cancel()
|
|
219
|
+
await asyncio.gather(pending_signal, return_exceptions=True)
|
|
220
|
+
return tool_running, pending_signal
|
|
221
|
+
|
|
222
|
+
async def _record_signal(
|
|
223
|
+
self,
|
|
224
|
+
signal: AgentSignal,
|
|
225
|
+
*,
|
|
226
|
+
agent: Agent,
|
|
227
|
+
task: Task,
|
|
228
|
+
role: Role,
|
|
229
|
+
rung: Rung,
|
|
230
|
+
run_id: int,
|
|
231
|
+
run_dir: Path,
|
|
232
|
+
tool_running: bool,
|
|
233
|
+
) -> tuple[bool, TurnEnded | TurnFailed | None]:
|
|
234
|
+
if isinstance(signal, SessionStarted):
|
|
235
|
+
self._sessions.record_session_started(
|
|
236
|
+
run_id=run_id,
|
|
237
|
+
agent=agent,
|
|
238
|
+
session_id=signal.session_id,
|
|
239
|
+
run_dir=run_dir,
|
|
240
|
+
rung=rung,
|
|
241
|
+
)
|
|
242
|
+
await self._refresh_titles(task)
|
|
243
|
+
elif isinstance(signal, Message):
|
|
244
|
+
self._runs.set_last_message(run_id, signal.text)
|
|
245
|
+
elif isinstance(signal, UsageReported):
|
|
246
|
+
self._runs.record_usage(run_id, signal.usage)
|
|
247
|
+
await self._refresh_titles(task)
|
|
248
|
+
elif isinstance(signal, ToolStarted):
|
|
249
|
+
tool_running = True
|
|
250
|
+
elif isinstance(signal, ToolFinished):
|
|
251
|
+
tool_running = False
|
|
252
|
+
elif isinstance(signal, TurnEnded):
|
|
253
|
+
self._runs.set_last_message(run_id, signal.last_message)
|
|
254
|
+
return tool_running, signal
|
|
255
|
+
elif isinstance(signal, TurnFailed):
|
|
256
|
+
return tool_running, signal
|
|
257
|
+
else:
|
|
258
|
+
await self._record_auxiliary_signal(signal, agent=agent, task=task, role=role)
|
|
259
|
+
return tool_running, None
|
|
260
|
+
|
|
261
|
+
async def _record_auxiliary_signal(
|
|
262
|
+
self, signal: AgentSignal, *, agent: Agent, task: Task, role: Role
|
|
263
|
+
) -> None:
|
|
264
|
+
if isinstance(signal, QuotaReported):
|
|
265
|
+
self._quotas.observe(agent.name, signal.windows)
|
|
266
|
+
elif isinstance(signal, Compacted):
|
|
267
|
+
self._events.append(
|
|
268
|
+
EventKind.COMPACTED,
|
|
269
|
+
task_id=task.id,
|
|
270
|
+
message=f"{task.id} {role.value} compacted context",
|
|
271
|
+
data={
|
|
272
|
+
"role": role.value,
|
|
273
|
+
"pre_tokens": signal.pre_tokens,
|
|
274
|
+
"post_tokens": signal.post_tokens,
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def _wait_for_signal(
|
|
279
|
+
self, signal_task: asyncio.Task[AgentSignal], timeout: float
|
|
280
|
+
) -> bool:
|
|
281
|
+
timer_task = asyncio.create_task(self._clock.sleep(timeout))
|
|
282
|
+
try:
|
|
283
|
+
done, _ = await asyncio.wait(
|
|
284
|
+
{signal_task, timer_task}, return_when=asyncio.FIRST_COMPLETED
|
|
285
|
+
)
|
|
286
|
+
return signal_task in done
|
|
287
|
+
finally:
|
|
288
|
+
if not timer_task.done():
|
|
289
|
+
timer_task.cancel()
|
|
290
|
+
await asyncio.gather(timer_task, return_exceptions=True)
|
|
291
|
+
|
|
292
|
+
def _append_view(
|
|
293
|
+
self, run_dir: Path, turn_dir: Path, role: Role, rung: Rung, signal: AgentSignal
|
|
294
|
+
) -> None:
|
|
295
|
+
path = view_log_path(run_dir, role.value)
|
|
296
|
+
make_private_dir(path.parent)
|
|
297
|
+
row = {
|
|
298
|
+
VIEW_TIME_KEY: self._clock.now().isoformat(),
|
|
299
|
+
VIEW_TURN_KEY: turn_dir.name,
|
|
300
|
+
VIEW_RUNG_KEY: asdict(rung),
|
|
301
|
+
VIEW_SIGNAL_KEY: type(signal).__name__,
|
|
302
|
+
**asdict(signal),
|
|
303
|
+
}
|
|
304
|
+
append_private_text(path, json.dumps(row, default=_json_value) + _LINE_ENDING)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _json_value(value: object) -> str:
|
|
308
|
+
if isinstance(value, datetime):
|
|
309
|
+
return value.isoformat()
|
|
310
|
+
if isinstance(value, Enum):
|
|
311
|
+
return str(value.value)
|
|
312
|
+
message = f"cannot encode {type(value).__name__} in a turn view"
|
|
313
|
+
raise TypeError(message)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def _next_signal(signals: AsyncIterator[AgentSignal]) -> AgentSignal:
|
|
317
|
+
return await signals.__anext__()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Off-loop, prioritised and bounded notification delivery for the engine daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections import deque
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from logging import Logger
|
|
13
|
+
|
|
14
|
+
from shikiru.domain.events import Event
|
|
15
|
+
from shikiru.engine.notifier import Notifier
|
|
16
|
+
|
|
17
|
+
INFORMATIONAL_QUEUE_SIZE = 64
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotificationDelivery:
|
|
21
|
+
"""Delivers events on two tasks, so a slow hook never blocks the engine loop or an escalation.
|
|
22
|
+
|
|
23
|
+
Urgent events (escalations, engine faults, fault stops) have a delivery task of their own, so
|
|
24
|
+
a stuck informational hook never delays one by more than the urgent hook in flight; they are
|
|
25
|
+
never dropped, and only the informational backlog is bounded. Each lane delivers one event at
|
|
26
|
+
a time. An urgent event leaves its queue only after its delivery, so
|
|
27
|
+
`oldest_undelivered_urgent_id` covers the one in flight.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, notifier: Notifier) -> None:
|
|
31
|
+
self._notifier = notifier
|
|
32
|
+
self._urgent: deque[Event] = deque()
|
|
33
|
+
self._informational: deque[Event] = deque(maxlen=INFORMATIONAL_QUEUE_SIZE)
|
|
34
|
+
self._urgent_pending = asyncio.Event()
|
|
35
|
+
self._informational_pending = asyncio.Event()
|
|
36
|
+
self._urgent_task: asyncio.Task[None] | None = None
|
|
37
|
+
self._informational_task: asyncio.Task[None] | None = None
|
|
38
|
+
self._logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
def start(self) -> None:
|
|
41
|
+
"""Start the urgent and the informational delivery tasks."""
|
|
42
|
+
self._urgent_task = asyncio.create_task(self._deliver_urgent())
|
|
43
|
+
self._informational_task = asyncio.create_task(self._deliver_informational())
|
|
44
|
+
|
|
45
|
+
def enqueue(self, event: Event) -> None:
|
|
46
|
+
"""Queue a notifiable `event`; a full informational backlog drops its oldest entry."""
|
|
47
|
+
if not self._notifier.wants(event):
|
|
48
|
+
return
|
|
49
|
+
if self._notifier.is_urgent(event):
|
|
50
|
+
self._urgent.append(event)
|
|
51
|
+
self._urgent_pending.set()
|
|
52
|
+
else:
|
|
53
|
+
if len(self._informational) == INFORMATIONAL_QUEUE_SIZE:
|
|
54
|
+
self._logger.warning(
|
|
55
|
+
"notification queue full; dropped event %s for event %s",
|
|
56
|
+
self._informational[0].id,
|
|
57
|
+
event.id,
|
|
58
|
+
)
|
|
59
|
+
self._informational.append(event)
|
|
60
|
+
self._informational_pending.set()
|
|
61
|
+
|
|
62
|
+
def replay(self, event: Event) -> None:
|
|
63
|
+
"""Queue `event` after a restart when it is urgent; informational ones are not replayed."""
|
|
64
|
+
if self._notifier.is_urgent(event):
|
|
65
|
+
self.enqueue(event)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def oldest_undelivered_urgent_id(self) -> int | None:
|
|
69
|
+
"""Return the id of the oldest urgent event not yet delivered, if any."""
|
|
70
|
+
return self._urgent[0].id if self._urgent else None
|
|
71
|
+
|
|
72
|
+
def supervise(self, logger: Logger) -> None:
|
|
73
|
+
"""Log a delivery task failure and start a replacement task."""
|
|
74
|
+
if _failed(self._urgent_task, logger):
|
|
75
|
+
self._urgent_task = asyncio.create_task(self._deliver_urgent())
|
|
76
|
+
if _failed(self._informational_task, logger):
|
|
77
|
+
self._informational_task = asyncio.create_task(self._deliver_informational())
|
|
78
|
+
|
|
79
|
+
async def shutdown(self) -> None:
|
|
80
|
+
"""Cancel the delivery tasks, terminating running hooks, without draining the queues."""
|
|
81
|
+
tasks = (self._urgent_task, self._informational_task)
|
|
82
|
+
self._urgent_task = self._informational_task = None
|
|
83
|
+
for task in tasks:
|
|
84
|
+
if task is not None:
|
|
85
|
+
task.cancel()
|
|
86
|
+
for task in tasks:
|
|
87
|
+
if task is not None:
|
|
88
|
+
with suppress(asyncio.CancelledError):
|
|
89
|
+
await task
|
|
90
|
+
|
|
91
|
+
async def deliver_final(self, event: Event, timeout_seconds: float) -> None:
|
|
92
|
+
"""Deliver `event` directly once the loop is over, giving up after `timeout_seconds`."""
|
|
93
|
+
try:
|
|
94
|
+
await asyncio.wait_for(self._notifier.notify(event), timeout_seconds)
|
|
95
|
+
except TimeoutError:
|
|
96
|
+
self._logger.warning("final notification for event %s timed out", event.id)
|
|
97
|
+
|
|
98
|
+
async def _deliver_urgent(self) -> None:
|
|
99
|
+
while True:
|
|
100
|
+
if self._urgent:
|
|
101
|
+
await self._notifier.notify(self._urgent[0])
|
|
102
|
+
self._urgent.popleft()
|
|
103
|
+
else:
|
|
104
|
+
self._urgent_pending.clear()
|
|
105
|
+
await self._urgent_pending.wait()
|
|
106
|
+
|
|
107
|
+
async def _deliver_informational(self) -> None:
|
|
108
|
+
while True:
|
|
109
|
+
if self._informational:
|
|
110
|
+
await self._notifier.notify(self._informational.popleft())
|
|
111
|
+
else:
|
|
112
|
+
self._informational_pending.clear()
|
|
113
|
+
await self._informational_pending.wait()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _failed(task: asyncio.Task[None] | None, logger: Logger) -> bool:
|
|
117
|
+
if task is None or not task.done():
|
|
118
|
+
return False
|
|
119
|
+
if not task.cancelled() and (error := task.exception()) is not None:
|
|
120
|
+
logger.error(
|
|
121
|
+
"notification delivery failed",
|
|
122
|
+
exc_info=(type(error), error, error.__traceback__),
|
|
123
|
+
)
|
|
124
|
+
return True
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Feeds new events to notification delivery and keeps the delivered-up-to cursor durable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from shikiru.engine.notification_delivery import NotificationDelivery
|
|
9
|
+
from shikiru.store.events import EventLog
|
|
10
|
+
from shikiru.store.notification_cursor import NotificationCursor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotificationFeed:
|
|
14
|
+
"""Queues events for delivery and persists the id every urgent event up to is delivered.
|
|
15
|
+
|
|
16
|
+
The cursor trails the oldest undelivered urgent event, so a crash or kill replays exactly the
|
|
17
|
+
urgent events (escalations, engine faults, fault stops) that were not yet delivered.
|
|
18
|
+
Informational events are never replayed. The cursor is written on the next engine poll after
|
|
19
|
+
a delivery, so an event delivered in that short window before a kill is delivered again.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self, events: EventLog, cursor: NotificationCursor, delivery: NotificationDelivery
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Read events from `events`, store the cursor in `cursor`, queue into `delivery`."""
|
|
26
|
+
self._events = events
|
|
27
|
+
self._cursor = cursor
|
|
28
|
+
self._delivery = delivery
|
|
29
|
+
self._scanned = 0
|
|
30
|
+
self._persisted: int | None = None
|
|
31
|
+
|
|
32
|
+
def restore(self) -> None:
|
|
33
|
+
"""Load the cursor and re-queue the urgent events it has not covered.
|
|
34
|
+
|
|
35
|
+
The first start of a project has no cursor and begins at the latest event.
|
|
36
|
+
"""
|
|
37
|
+
self._persisted = self._cursor.read()
|
|
38
|
+
self._scanned = self._events.latest_id() if self._persisted is None else self._persisted
|
|
39
|
+
for event in self._events.since(self._scanned):
|
|
40
|
+
self._scanned = event.id
|
|
41
|
+
self._delivery.replay(event)
|
|
42
|
+
self.persist()
|
|
43
|
+
|
|
44
|
+
def scan(self) -> None:
|
|
45
|
+
"""Queue the events appended since the last scan and persist the cursor."""
|
|
46
|
+
for event in self._events.since(self._scanned):
|
|
47
|
+
self._scanned = event.id
|
|
48
|
+
self._delivery.enqueue(event)
|
|
49
|
+
self.persist()
|
|
50
|
+
|
|
51
|
+
def persist(self) -> None:
|
|
52
|
+
"""Write the cursor when it moved."""
|
|
53
|
+
oldest = self._delivery.oldest_undelivered_urgent_id
|
|
54
|
+
delivered_up_to = self._scanned if oldest is None else oldest - 1
|
|
55
|
+
if delivered_up_to != self._persisted:
|
|
56
|
+
self._cursor.write(delivered_up_to)
|
|
57
|
+
self._persisted = delivered_up_to
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""tmux and external command notifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import shlex
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.domain.events import STOP_REASON_KEY, Event, EventKind
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from shikiru.engine.fault_reporter import FaultReporter
|
|
14
|
+
from shikiru.process import SubprocessRunner
|
|
15
|
+
from shikiru.tmux.client import Tmux
|
|
16
|
+
|
|
17
|
+
_URGENT = frozenset({EventKind.ESCALATION, EventKind.ENGINE_FAULT})
|
|
18
|
+
_NOTIFIABLE = frozenset(
|
|
19
|
+
{
|
|
20
|
+
EventKind.ESCALATION,
|
|
21
|
+
EventKind.TASK_DONE,
|
|
22
|
+
EventKind.ENGINE_FAULT,
|
|
23
|
+
EventKind.INTEGRATION_WAITING,
|
|
24
|
+
EventKind.TAKEOVER_LOST,
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
HOOK_FAULT_SCOPE = "notification hook"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Notifier:
|
|
31
|
+
"""Displays escalation/completion events in tmux and invokes `notify.command`."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
tmux: Tmux,
|
|
36
|
+
runner: SubprocessRunner,
|
|
37
|
+
reporter: FaultReporter,
|
|
38
|
+
*,
|
|
39
|
+
session_target: str,
|
|
40
|
+
command: str,
|
|
41
|
+
timeout_seconds: float,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Use `tmux`, `runner`, the project session and an optional argv command.
|
|
44
|
+
|
|
45
|
+
A failing hook is recorded through `reporter`, once per streak.
|
|
46
|
+
"""
|
|
47
|
+
self._tmux = tmux
|
|
48
|
+
self._runner = runner
|
|
49
|
+
self._reporter = reporter
|
|
50
|
+
self._session_target = session_target
|
|
51
|
+
self._command = command
|
|
52
|
+
self._timeout_seconds = timeout_seconds
|
|
53
|
+
self._logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def wants(event: Event) -> bool:
|
|
57
|
+
"""Return whether `event` is an escalation, completion, fault, merge wait or fault stop."""
|
|
58
|
+
return event.kind in _NOTIFIABLE or (
|
|
59
|
+
event.kind is EventKind.ENGINE_STOPPED and STOP_REASON_KEY in event.data
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def is_urgent(event: Event) -> bool:
|
|
64
|
+
"""Return whether `event` is an escalation, an engine fault or a fault stop."""
|
|
65
|
+
return event.kind in _URGENT or (
|
|
66
|
+
event.kind is EventKind.ENGINE_STOPPED and STOP_REASON_KEY in event.data
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def notify(self, event: Event) -> None:
|
|
70
|
+
"""Deliver `event` when it is an escalation or task completion."""
|
|
71
|
+
if not self.wants(event):
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
await self._tmux.display_message(self._session_target, event.message)
|
|
75
|
+
except Exception:
|
|
76
|
+
self._logger.exception("tmux notification failed for event %s", event.id)
|
|
77
|
+
if self._command.strip():
|
|
78
|
+
try:
|
|
79
|
+
argv = tuple(shlex.split(self._command))
|
|
80
|
+
payload = json.dumps(event.model_dump(mode="json"))
|
|
81
|
+
await self._runner.run(
|
|
82
|
+
argv,
|
|
83
|
+
timeout=self._timeout_seconds,
|
|
84
|
+
input_text=payload,
|
|
85
|
+
check=True,
|
|
86
|
+
)
|
|
87
|
+
except Exception as error:
|
|
88
|
+
self._logger.exception("external notification failed for event %s", event.id)
|
|
89
|
+
self._reporter.report(error, scope=HOOK_FAULT_SCOPE)
|
|
90
|
+
else:
|
|
91
|
+
self._reporter.resolve(HOOK_FAULT_SCOPE)
|