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,90 @@
|
|
|
1
|
+
"""Acceptance of a completed turn's report: a dirty worktree cannot end a task's work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.reports import (
|
|
8
|
+
DECISION_ACCEPT,
|
|
9
|
+
DECISION_CHANGES_REQUESTED,
|
|
10
|
+
DECISION_SUPERSEDE,
|
|
11
|
+
REPORT_JSON_INDENT,
|
|
12
|
+
SEVERITY_BLOCKER,
|
|
13
|
+
FeedbackVerdict,
|
|
14
|
+
Issue,
|
|
15
|
+
Verdict,
|
|
16
|
+
)
|
|
17
|
+
from shikiru.engine.events import emit_turn_completed, emit_verdict
|
|
18
|
+
from shikiru.engine.history import ENGINE_VERDICT_FILENAME
|
|
19
|
+
from shikiru.engine.task_facts import require_repo
|
|
20
|
+
from shikiru.private import write_private_text
|
|
21
|
+
from shikiru.workspace.facts import describe_off_branch
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from shikiru.domain.steps import Outcome
|
|
25
|
+
from shikiru.domain.tasks import Task
|
|
26
|
+
from shikiru.engine.task_facts import TaskFacts
|
|
27
|
+
from shikiru.engine.turn_execution import TurnExecution
|
|
28
|
+
from shikiru.store.events import EventLog
|
|
29
|
+
from shikiru.workspace.facts import WorkspaceFacts
|
|
30
|
+
|
|
31
|
+
_WORK_ENDING_DECISIONS = frozenset({DECISION_ACCEPT, DECISION_SUPERSEDE})
|
|
32
|
+
REJECTION_SUMMARY = "The workspace cannot be integrated as it is."
|
|
33
|
+
UNCOMMITTED_ISSUE = (
|
|
34
|
+
"Uncommitted changes in the workspace: {paths}. "
|
|
35
|
+
"Commit what belongs to this task and discard the rest."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _blocker(task: Task, facts: WorkspaceFacts) -> str | None:
|
|
40
|
+
branch = require_repo(task).branch
|
|
41
|
+
if facts.head_branch != branch:
|
|
42
|
+
return describe_off_branch(branch, facts.head_branch, facts.commits)
|
|
43
|
+
if facts.status:
|
|
44
|
+
paths = ", ".join(line.strip() for line in facts.status)
|
|
45
|
+
return UNCOMMITTED_ISSUE.format(paths=paths)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AcceptanceGuard:
|
|
50
|
+
"""Records a completed turn and rejects an accepting verdict over a dirty worktree."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *, events: EventLog, facts: TaskFacts) -> None:
|
|
53
|
+
"""Record through `events` and inspect the worktree through `facts`."""
|
|
54
|
+
self._events = events
|
|
55
|
+
self._facts = facts
|
|
56
|
+
|
|
57
|
+
async def settle[R: Outcome](
|
|
58
|
+
self, task: Task, report: R, execution: TurnExecution
|
|
59
|
+
) -> R | Verdict:
|
|
60
|
+
"""Record the completed turn; return `report`, or the engine's rejection of a dirty accept.
|
|
61
|
+
|
|
62
|
+
An `accept` or `supersede` verdict ends the task's work, so uncommitted changes in its
|
|
63
|
+
worktree replace it with a `changes_requested` verdict written beside the turn's report.
|
|
64
|
+
"""
|
|
65
|
+
emit_turn_completed(self._events, task, report, execution)
|
|
66
|
+
if not isinstance(report, Verdict) or report.decision not in _WORK_ENDING_DECISIONS:
|
|
67
|
+
return report
|
|
68
|
+
facts = await self._facts.collect(task)
|
|
69
|
+
description = _blocker(task, facts)
|
|
70
|
+
if description is None:
|
|
71
|
+
return report
|
|
72
|
+
rejection_type = FeedbackVerdict if isinstance(report, FeedbackVerdict) else Verdict
|
|
73
|
+
rejection = rejection_type(
|
|
74
|
+
decision=DECISION_CHANGES_REQUESTED,
|
|
75
|
+
summary=REJECTION_SUMMARY,
|
|
76
|
+
issues=[Issue(severity=SEVERITY_BLOCKER, description=description)],
|
|
77
|
+
)
|
|
78
|
+
write_private_text(
|
|
79
|
+
execution.turn_dir / ENGINE_VERDICT_FILENAME,
|
|
80
|
+
rejection.model_dump_json(indent=REPORT_JSON_INDENT),
|
|
81
|
+
)
|
|
82
|
+
emit_verdict(
|
|
83
|
+
self._events,
|
|
84
|
+
task,
|
|
85
|
+
rejection,
|
|
86
|
+
rejection.summary,
|
|
87
|
+
turn=execution.turn_dir.name,
|
|
88
|
+
role=execution.role,
|
|
89
|
+
)
|
|
90
|
+
return rejection
|
shikiru/engine/agents.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""`AgentPool`: builds and caches one headless `Agent` per name.
|
|
2
|
+
|
|
3
|
+
Builds through an injected `create` callable rather than an `AgentRegistry` directly, keeping
|
|
4
|
+
the engine on the shared `agents.contracts` boundary: callers pass `AgentRegistry.create` bound,
|
|
5
|
+
but the engine itself never imports `agents.registry`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from shikiru.agents.contracts import AgentContext, BillingDeclaration, billing_declaration_for
|
|
13
|
+
from shikiru.errors import ConfigError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from shikiru.agents.contracts import Agent
|
|
19
|
+
from shikiru.clock import Clock
|
|
20
|
+
from shikiru.config.settings import Settings
|
|
21
|
+
from shikiru.process import SubprocessRunner
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentPool:
|
|
25
|
+
"""Resolves a rung's agent name to its `Agent`, built once and cached."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
create: Callable[[str, AgentContext], Agent],
|
|
30
|
+
runner: SubprocessRunner,
|
|
31
|
+
settings: Settings,
|
|
32
|
+
clock: Clock,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Build agents by calling `create(name, context)`, timed by `clock`."""
|
|
35
|
+
self._create = create
|
|
36
|
+
self._runner = runner
|
|
37
|
+
self._settings = settings
|
|
38
|
+
self._clock = clock
|
|
39
|
+
self._agents: dict[str, Agent] = {}
|
|
40
|
+
|
|
41
|
+
def get(self, name: str) -> Agent:
|
|
42
|
+
"""Return `name`'s headless `Agent`, building and caching it on first use.
|
|
43
|
+
|
|
44
|
+
Raises `ConfigError` if `name` has no `[agents.<name>]` table: a rung naming an agent
|
|
45
|
+
the project never configured is a configuration mistake, not a default to paper over.
|
|
46
|
+
"""
|
|
47
|
+
cached = self._agents.get(name)
|
|
48
|
+
if cached is not None:
|
|
49
|
+
return cached
|
|
50
|
+
config = self._settings.agents.get(name)
|
|
51
|
+
if config is None:
|
|
52
|
+
message = f"agent {name!r} is used by a rung but has no [agents.{name}] table"
|
|
53
|
+
raise ConfigError(message)
|
|
54
|
+
context = AgentContext(options=config.options, runner=self._runner, clock=self._clock)
|
|
55
|
+
agent = self._create(name, context)
|
|
56
|
+
self._agents[name] = agent
|
|
57
|
+
return agent
|
|
58
|
+
|
|
59
|
+
def billing_declaration(self, name: str) -> BillingDeclaration:
|
|
60
|
+
"""Return the configured plugin's optional declaration through the contract boundary."""
|
|
61
|
+
return billing_declaration_for(self.get(name))
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Decorations that never fail the engine path that sets them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.errors import PROCESS_FAILURES
|
|
9
|
+
from shikiru.tmux.decorator import PaneDecorator
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Awaitable, Mapping
|
|
13
|
+
|
|
14
|
+
from shikiru.tmux.client import Tmux
|
|
15
|
+
|
|
16
|
+
COSMETIC_LOGGER_NAME = __name__
|
|
17
|
+
_LOGGER = logging.getLogger(COSMETIC_LOGGER_NAME)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CosmeticDecorator(PaneDecorator):
|
|
21
|
+
"""A `PaneDecorator` whose failing tmux calls, timeouts included, are logged instead of raised.
|
|
22
|
+
|
|
23
|
+
A streak of failures of one operation is logged once, at its first failure, and ends with
|
|
24
|
+
that operation's next success.
|
|
25
|
+
|
|
26
|
+
A lost pane or server is repaired by the layout guard and must not end a task's flow or a
|
|
27
|
+
command.
|
|
28
|
+
|
|
29
|
+
A title template that cannot be rendered still raises `ConfigError`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, tmux: Tmux) -> None:
|
|
33
|
+
"""Decorate through `tmux`."""
|
|
34
|
+
super().__init__(tmux)
|
|
35
|
+
self._failing: set[str] = set()
|
|
36
|
+
|
|
37
|
+
async def _cosmetic(self, name: str, operation: Awaitable[None]) -> None:
|
|
38
|
+
try:
|
|
39
|
+
await operation
|
|
40
|
+
except PROCESS_FAILURES:
|
|
41
|
+
if name not in self._failing:
|
|
42
|
+
_LOGGER.exception("tmux %s failed", name)
|
|
43
|
+
self._failing.add(name)
|
|
44
|
+
else:
|
|
45
|
+
self._failing.discard(name)
|
|
46
|
+
|
|
47
|
+
async def configure_formats(self, window_target: str) -> None:
|
|
48
|
+
"""Set the window formats, logging a tmux failure."""
|
|
49
|
+
await self._cosmetic("configure_formats", super().configure_formats(window_target))
|
|
50
|
+
|
|
51
|
+
async def set_pane_state(self, pane_target: str, *, color: str, glyph: str) -> None:
|
|
52
|
+
"""Set the pane state, logging a tmux failure."""
|
|
53
|
+
await self._cosmetic(
|
|
54
|
+
"set_pane_state", super().set_pane_state(pane_target, color=color, glyph=glyph)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def refresh_window(
|
|
58
|
+
self,
|
|
59
|
+
window_target: str,
|
|
60
|
+
template: str,
|
|
61
|
+
fields: Mapping[str, object],
|
|
62
|
+
*,
|
|
63
|
+
color: str,
|
|
64
|
+
glyph: str,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Set the window state and title, logging a tmux failure."""
|
|
67
|
+
await self._cosmetic(
|
|
68
|
+
"refresh_window",
|
|
69
|
+
super().refresh_window(window_target, template, fields, color=color, glyph=glyph),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def set_pane_title(
|
|
73
|
+
self, pane_target: str, template: str, fields: Mapping[str, object]
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Set the pane title, logging a tmux failure."""
|
|
76
|
+
await self._cosmetic(
|
|
77
|
+
"set_pane_title", super().set_pane_title(pane_target, template, fields)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def set_pane_title_styling(
|
|
81
|
+
self,
|
|
82
|
+
pane_target: str,
|
|
83
|
+
template: str,
|
|
84
|
+
fields: Mapping[str, object],
|
|
85
|
+
*,
|
|
86
|
+
field_colors: Mapping[str, str],
|
|
87
|
+
field_segments: Mapping[str, tuple[tuple[str, str], ...]],
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Set pane-title component colors, logging a tmux failure."""
|
|
90
|
+
await self._cosmetic(
|
|
91
|
+
"set_pane_title_styling",
|
|
92
|
+
super().set_pane_title_styling(
|
|
93
|
+
pane_target,
|
|
94
|
+
template,
|
|
95
|
+
fields,
|
|
96
|
+
field_colors=field_colors,
|
|
97
|
+
field_segments=field_segments,
|
|
98
|
+
),
|
|
99
|
+
)
|
shikiru/engine/daemon.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""The asyncio engine daemon: liveness, command dispatch and task supervision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from typing import TYPE_CHECKING, Self
|
|
10
|
+
|
|
11
|
+
import psutil
|
|
12
|
+
|
|
13
|
+
from shikiru.domain.commands import COMMAND_POLL_SECONDS, Stop
|
|
14
|
+
from shikiru.domain.engine_uptime import (
|
|
15
|
+
ENGINE_PID_FIELD,
|
|
16
|
+
ENGINE_PROCESS_STARTED_AT_FIELD,
|
|
17
|
+
)
|
|
18
|
+
from shikiru.domain.events import STOP_REASON_KEY, EventKind
|
|
19
|
+
from shikiru.errors import ShikiruError
|
|
20
|
+
from shikiru.private import create_private_file, make_private_dir
|
|
21
|
+
from shikiru.process import ProcessIdentity, identify_pid
|
|
22
|
+
from shikiru.store.database import OperationalDatabaseError
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Awaitable, Callable
|
|
26
|
+
from logging import FileHandler, Logger
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TextIO
|
|
29
|
+
|
|
30
|
+
from shikiru.clock import Clock
|
|
31
|
+
from shikiru.config.settings import Settings
|
|
32
|
+
from shikiru.domain.events import Event
|
|
33
|
+
from shikiru.engine.handlers import HandlerRegistry
|
|
34
|
+
from shikiru.engine.layout_guard import LayoutGuard
|
|
35
|
+
from shikiru.engine.notification_delivery import NotificationDelivery
|
|
36
|
+
from shikiru.engine.notification_feed import NotificationFeed
|
|
37
|
+
from shikiru.engine.pull_request_closing import PullRequestClosing
|
|
38
|
+
from shikiru.engine.push_service import PushService
|
|
39
|
+
from shikiru.engine.reconcile import Reconciler
|
|
40
|
+
from shikiru.engine.retention import Retention
|
|
41
|
+
from shikiru.engine.scheduler import Scheduler
|
|
42
|
+
from shikiru.engine.session_guard import SessionGuard
|
|
43
|
+
from shikiru.engine.supervisor import TaskSupervisor
|
|
44
|
+
from shikiru.engine.tick_guard import TickGuard
|
|
45
|
+
from shikiru.store.commands import CommandQueue
|
|
46
|
+
from shikiru.store.events import EventLog
|
|
47
|
+
from shikiru.store.tasks import TaskStore
|
|
48
|
+
|
|
49
|
+
ENGINE_LOGGER_NAME = "shikiru"
|
|
50
|
+
_ENGINE_LOCK_METADATA_PARTS = 2
|
|
51
|
+
ENGINE_LOCK_PROBE_GRACE_SECONDS = 1.0
|
|
52
|
+
ENGINE_LOCK_RETRY_SECONDS = 0.02
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EngineAlreadyRunningError(ShikiruError):
|
|
56
|
+
"""The project already has an engine holding its exclusive lock."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EngineLock:
|
|
60
|
+
"""An exclusive process lock held for exactly one engine lifetime."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, path: Path) -> None:
|
|
63
|
+
self._path = path
|
|
64
|
+
self._file: TextIO | None = None
|
|
65
|
+
self._identity: ProcessIdentity | None = None
|
|
66
|
+
|
|
67
|
+
def acquire(self) -> None:
|
|
68
|
+
"""Acquire the lock without blocking, or raise `EngineAlreadyRunningError`."""
|
|
69
|
+
make_private_dir(self._path.parent)
|
|
70
|
+
create_private_file(self._path)
|
|
71
|
+
self._file = self._path.open("a+")
|
|
72
|
+
try:
|
|
73
|
+
fcntl.flock(self._file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
74
|
+
except BlockingIOError:
|
|
75
|
+
self._file.close()
|
|
76
|
+
self._file = None
|
|
77
|
+
message = f"engine lock is held: {self._path}"
|
|
78
|
+
raise EngineAlreadyRunningError(message) from None
|
|
79
|
+
self._file.seek(0)
|
|
80
|
+
self._file.truncate()
|
|
81
|
+
identity = ProcessIdentity(os.getpid(), psutil.Process(os.getpid()).create_time())
|
|
82
|
+
self._identity = identity
|
|
83
|
+
self._file.write(f"{identity.pid} {identity.started_at!r}")
|
|
84
|
+
self._file.flush()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def identity(self) -> ProcessIdentity:
|
|
88
|
+
"""Return this lock holder's recorded process identity."""
|
|
89
|
+
identity = self._identity
|
|
90
|
+
if identity is None:
|
|
91
|
+
message = "engine lock identity is unavailable before acquisition"
|
|
92
|
+
raise RuntimeError(message)
|
|
93
|
+
return identity
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def identity_if_running(path: Path) -> ProcessIdentity | None:
|
|
97
|
+
"""Return the verified identity written by the process currently holding `path`."""
|
|
98
|
+
try:
|
|
99
|
+
lock_file = path.open()
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
return None
|
|
102
|
+
with lock_file:
|
|
103
|
+
try:
|
|
104
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
105
|
+
except BlockingIOError:
|
|
106
|
+
lock_file.seek(0)
|
|
107
|
+
contents = lock_file.read().split()
|
|
108
|
+
if len(contents) != _ENGINE_LOCK_METADATA_PARTS:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
pid = int(contents[0])
|
|
112
|
+
process_started_at = float(contents[1])
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
|
115
|
+
if pid < 1 or not math.isfinite(process_started_at):
|
|
116
|
+
return None
|
|
117
|
+
identity = ProcessIdentity(pid, process_started_at)
|
|
118
|
+
return identity if identity.matches(identify_pid(identity.pid)) else None
|
|
119
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def pid_if_running(path: Path) -> int | None:
|
|
124
|
+
"""Return the verified PID written by the process that currently holds `path`."""
|
|
125
|
+
identity = EngineLock.identity_if_running(path)
|
|
126
|
+
return identity.pid if identity is not None else None
|
|
127
|
+
|
|
128
|
+
def release(self) -> None:
|
|
129
|
+
"""Release the lock and close its file descriptor."""
|
|
130
|
+
if self._file is None:
|
|
131
|
+
return
|
|
132
|
+
fcntl.flock(self._file.fileno(), fcntl.LOCK_UN)
|
|
133
|
+
self._file.close()
|
|
134
|
+
self._file = None
|
|
135
|
+
self._identity = None
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> Self:
|
|
138
|
+
self.acquire()
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def __exit__(self, _type: object, _value: object, _traceback: object) -> None:
|
|
142
|
+
self.release()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def engine_is_running(lock_path: Path) -> bool:
|
|
146
|
+
"""Return whether another process currently holds `lock_path`."""
|
|
147
|
+
try:
|
|
148
|
+
lock_file = lock_path.open("r")
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
return False
|
|
151
|
+
try:
|
|
152
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
153
|
+
except BlockingIOError:
|
|
154
|
+
return True
|
|
155
|
+
else:
|
|
156
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
157
|
+
return False
|
|
158
|
+
finally:
|
|
159
|
+
lock_file.close()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Engine:
|
|
163
|
+
"""Poll commands, run the scheduler and supervise one coroutine per active task."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
lock_path: Path,
|
|
169
|
+
command_queue: CommandQueue,
|
|
170
|
+
tasks: TaskStore,
|
|
171
|
+
events: EventLog,
|
|
172
|
+
scheduler: Scheduler,
|
|
173
|
+
task_supervisor: TaskSupervisor,
|
|
174
|
+
handlers: HandlerRegistry,
|
|
175
|
+
layout: LayoutGuard,
|
|
176
|
+
session_guard: SessionGuard,
|
|
177
|
+
tick_guard: TickGuard,
|
|
178
|
+
command_guard: TickGuard,
|
|
179
|
+
loop_guard: TickGuard,
|
|
180
|
+
reconciler: Reconciler,
|
|
181
|
+
notifications: NotificationDelivery,
|
|
182
|
+
notification_feed: NotificationFeed,
|
|
183
|
+
retention: Retention,
|
|
184
|
+
retention_guard: TickGuard,
|
|
185
|
+
pull_request_closing: PullRequestClosing,
|
|
186
|
+
push_service: PushService | None,
|
|
187
|
+
settings: Settings,
|
|
188
|
+
clock: Clock,
|
|
189
|
+
close_state: Callable[[], None],
|
|
190
|
+
logger: Logger,
|
|
191
|
+
log_handler: FileHandler,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Wire the daemon loop to its stores and bootstrap-built command registry."""
|
|
194
|
+
self._lock = EngineLock(lock_path)
|
|
195
|
+
self._commands = command_queue
|
|
196
|
+
self._tasks = tasks
|
|
197
|
+
self._events = events
|
|
198
|
+
self._scheduler = scheduler
|
|
199
|
+
self._task_supervisor = task_supervisor
|
|
200
|
+
self._handlers = handlers
|
|
201
|
+
self._layout = layout
|
|
202
|
+
self._session_guard = session_guard
|
|
203
|
+
self._tick_guard = tick_guard
|
|
204
|
+
self._command_guard = command_guard
|
|
205
|
+
self._loop_guard = loop_guard
|
|
206
|
+
self._reconciled = False
|
|
207
|
+
self._stop_reason: str | None = None
|
|
208
|
+
self._stopped_event: Event | None = None
|
|
209
|
+
self._reconciler = reconciler
|
|
210
|
+
self._notifications = notifications
|
|
211
|
+
self._notification_feed = notification_feed
|
|
212
|
+
self._retention = retention
|
|
213
|
+
self._retention_guard = retention_guard
|
|
214
|
+
self._pull_request_closing = pull_request_closing
|
|
215
|
+
self._push_service = push_service
|
|
216
|
+
self._settings = settings
|
|
217
|
+
self._clock = clock
|
|
218
|
+
self._close_state = close_state
|
|
219
|
+
self._last_scheduled_event = 0
|
|
220
|
+
self._logger = logger
|
|
221
|
+
self._log_handler = log_handler
|
|
222
|
+
|
|
223
|
+
async def _acquire_lock(self) -> None:
|
|
224
|
+
deadline = self._clock.now() + timedelta(seconds=ENGINE_LOCK_PROBE_GRACE_SECONDS)
|
|
225
|
+
while True:
|
|
226
|
+
try:
|
|
227
|
+
self._lock.acquire()
|
|
228
|
+
except EngineAlreadyRunningError:
|
|
229
|
+
if self._clock.now() >= deadline:
|
|
230
|
+
raise
|
|
231
|
+
await self._clock.sleep(ENGINE_LOCK_RETRY_SECONDS)
|
|
232
|
+
else:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
async def run(self) -> None:
|
|
236
|
+
"""Hold liveness, process commands and schedule tasks until `Stop` or cancellation."""
|
|
237
|
+
try:
|
|
238
|
+
await self._acquire_lock()
|
|
239
|
+
except BaseException:
|
|
240
|
+
self._close_logging()
|
|
241
|
+
self._close_state()
|
|
242
|
+
raise
|
|
243
|
+
try:
|
|
244
|
+
await self._until_done(self._restore_notifications)
|
|
245
|
+
self._notifications.start()
|
|
246
|
+
self._pull_request_closing.start()
|
|
247
|
+
await self._until_done(self._record_start)
|
|
248
|
+
next_tick = self._clock.now()
|
|
249
|
+
while not self._task_supervisor.stop_requested:
|
|
250
|
+
await self._task_supervisor.supervise(self._logger)
|
|
251
|
+
self._notifications.supervise(self._logger)
|
|
252
|
+
self._pull_request_closing.supervise(self._logger)
|
|
253
|
+
if not self._reconciled:
|
|
254
|
+
await self._tick_guard.attempt(self._prepare)
|
|
255
|
+
commands_changed = bool(await self._command_guard.attempt(self._process_commands))
|
|
256
|
+
if self._task_supervisor.stop_requested:
|
|
257
|
+
break
|
|
258
|
+
now = self._clock.now()
|
|
259
|
+
events_changed = await self._loop_guard.attempt(self._new_events) or []
|
|
260
|
+
if events_changed:
|
|
261
|
+
self._last_scheduled_event = max(event.id for event in events_changed)
|
|
262
|
+
if commands_changed or events_changed or now >= next_tick:
|
|
263
|
+
await self._tick_guard.attempt(self._tick)
|
|
264
|
+
next_tick = now + timedelta(seconds=self._settings.orchestration.poll_seconds)
|
|
265
|
+
if self._reconciled and self._push_service is not None:
|
|
266
|
+
self._push_service.poll(self._logger)
|
|
267
|
+
if self._reconciled:
|
|
268
|
+
await self._retention_guard.attempt(self._retention.run_if_due)
|
|
269
|
+
await self._loop_guard.attempt(self._scan_notifications)
|
|
270
|
+
await self._clock.sleep(COMMAND_POLL_SECONDS)
|
|
271
|
+
await self._task_supervisor.supervise(self._logger)
|
|
272
|
+
except Exception as error:
|
|
273
|
+
self._stop_reason = f"{type(error).__name__}: {error}"
|
|
274
|
+
self._logger.exception("engine stopping on an unrecoverable fault")
|
|
275
|
+
raise
|
|
276
|
+
finally:
|
|
277
|
+
await self._shutdown()
|
|
278
|
+
|
|
279
|
+
def request_stop(self) -> None:
|
|
280
|
+
"""Ask the command loop to stop after acknowledging the current command."""
|
|
281
|
+
self._task_supervisor.request_stop()
|
|
282
|
+
|
|
283
|
+
async def _until_done(self, step: Callable[[], Awaitable[bool]]) -> None:
|
|
284
|
+
while await self._loop_guard.attempt(step) is None:
|
|
285
|
+
if self._task_supervisor.stop_requested:
|
|
286
|
+
return
|
|
287
|
+
await self._clock.sleep(COMMAND_POLL_SECONDS)
|
|
288
|
+
|
|
289
|
+
async def _restore_notifications(self) -> bool:
|
|
290
|
+
self._notification_feed.restore()
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
async def _record_start(self) -> bool:
|
|
294
|
+
self._events.append(
|
|
295
|
+
EventKind.ENGINE_STARTED,
|
|
296
|
+
message="engine started",
|
|
297
|
+
data=_engine_identity_data(self._lock.identity),
|
|
298
|
+
)
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
async def _new_events(self) -> list[Event]:
|
|
302
|
+
return self._events.since(self._last_scheduled_event)
|
|
303
|
+
|
|
304
|
+
async def _scan_notifications(self) -> None:
|
|
305
|
+
self._notification_feed.scan()
|
|
306
|
+
|
|
307
|
+
async def _prepare(self) -> None:
|
|
308
|
+
await self._session_guard.ensure()
|
|
309
|
+
if not self._reconciled:
|
|
310
|
+
await self._reconciler.reconcile()
|
|
311
|
+
self._reconciled = True
|
|
312
|
+
|
|
313
|
+
async def _tick(self) -> None:
|
|
314
|
+
await self._prepare()
|
|
315
|
+
await self._layout.restore(self._tasks.list_active())
|
|
316
|
+
schedule = await self._scheduler.run_once()
|
|
317
|
+
for dispatch in schedule.dispatches:
|
|
318
|
+
await self._task_supervisor.start_task(dispatch.task_id)
|
|
319
|
+
await self._task_supervisor.ensure_flows()
|
|
320
|
+
await self._reconciler.restore_escalations()
|
|
321
|
+
scheduled_events = self._events.since(self._last_scheduled_event)
|
|
322
|
+
if scheduled_events:
|
|
323
|
+
self._last_scheduled_event = max(event.id for event in scheduled_events)
|
|
324
|
+
|
|
325
|
+
async def _process_commands(self) -> bool:
|
|
326
|
+
changed = False
|
|
327
|
+
for queued in self._commands.pending():
|
|
328
|
+
if not self._reconciled and not isinstance(queued.command, Stop):
|
|
329
|
+
continue
|
|
330
|
+
try:
|
|
331
|
+
result = await self._handlers.handle(queued.command)
|
|
332
|
+
except ShikiruError as error:
|
|
333
|
+
self._commands.mark_rejected(queued.id, {"error": str(error)})
|
|
334
|
+
continue
|
|
335
|
+
except OperationalDatabaseError:
|
|
336
|
+
raise
|
|
337
|
+
except Exception as error:
|
|
338
|
+
self._commands.mark_rejected(queued.id, {"error": str(error)})
|
|
339
|
+
raise
|
|
340
|
+
self._commands.mark_done(queued.id, result)
|
|
341
|
+
changed = True
|
|
342
|
+
return changed
|
|
343
|
+
|
|
344
|
+
async def _shutdown(self) -> None:
|
|
345
|
+
self._stopped_event = None
|
|
346
|
+
failures: list[Exception] = []
|
|
347
|
+
try:
|
|
348
|
+
for step in (
|
|
349
|
+
self._record_stop,
|
|
350
|
+
self._notifications.shutdown,
|
|
351
|
+
self._persist_notification_cursor,
|
|
352
|
+
self._pull_request_closing.shutdown,
|
|
353
|
+
self._shutdown_pushes,
|
|
354
|
+
self._deliver_stop,
|
|
355
|
+
self._scheduler.shutdown,
|
|
356
|
+
self._task_supervisor.shutdown,
|
|
357
|
+
):
|
|
358
|
+
try:
|
|
359
|
+
await step()
|
|
360
|
+
except Exception as error:
|
|
361
|
+
self._logger.exception("engine shutdown step failed")
|
|
362
|
+
failures.append(error)
|
|
363
|
+
finally:
|
|
364
|
+
try:
|
|
365
|
+
self._lock.release()
|
|
366
|
+
finally:
|
|
367
|
+
self._close_logging()
|
|
368
|
+
self._close_state()
|
|
369
|
+
if failures:
|
|
370
|
+
raise failures[0]
|
|
371
|
+
|
|
372
|
+
async def _record_stop(self) -> None:
|
|
373
|
+
reason = self._stop_reason
|
|
374
|
+
self._stopped_event = self._events.append(
|
|
375
|
+
EventKind.ENGINE_STOPPED,
|
|
376
|
+
message="engine stopped" if reason is None else f"engine stopped: {reason}",
|
|
377
|
+
data={
|
|
378
|
+
**_engine_identity_data(self._lock.identity),
|
|
379
|
+
**({} if reason is None else {STOP_REASON_KEY: reason}),
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
async def _persist_notification_cursor(self) -> None:
|
|
384
|
+
self._notification_feed.persist()
|
|
385
|
+
|
|
386
|
+
async def _shutdown_pushes(self) -> None:
|
|
387
|
+
if self._push_service is not None:
|
|
388
|
+
await self._push_service.shutdown()
|
|
389
|
+
|
|
390
|
+
async def _deliver_stop(self) -> None:
|
|
391
|
+
if self._stop_reason is not None and self._stopped_event is not None:
|
|
392
|
+
await self._notifications.deliver_final(
|
|
393
|
+
self._stopped_event, self._settings.orchestration.faults.stop_notify_seconds
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def _close_logging(self) -> None:
|
|
397
|
+
handler = self._log_handler
|
|
398
|
+
self._logger.removeHandler(handler)
|
|
399
|
+
handler.close()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _engine_identity_data(identity: ProcessIdentity) -> dict[str, int | float]:
|
|
403
|
+
if identity.started_at is None:
|
|
404
|
+
message = "engine process start time is unavailable"
|
|
405
|
+
raise RuntimeError(message)
|
|
406
|
+
return {
|
|
407
|
+
ENGINE_PID_FIELD: identity.pid,
|
|
408
|
+
ENGINE_PROCESS_STARTED_AT_FIELD: identity.started_at,
|
|
409
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Dependency-cancellation escalation orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.steps import Transition
|
|
8
|
+
from shikiru.domain.tasks import EscalationReason
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from shikiru.domain.tasks import Task
|
|
12
|
+
from shikiru.engine.escalations import EscalationRaiser
|
|
13
|
+
from shikiru.engine.transitions import TransitionWriter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DependencyEscalator:
|
|
17
|
+
"""Apply and packet a dependency-cancelled escalation through one boundary."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, writer: TransitionWriter, raiser: EscalationRaiser) -> None:
|
|
20
|
+
"""Use `writer` for the state transition and `raiser` for the operator packet."""
|
|
21
|
+
self._writer = writer
|
|
22
|
+
self._raiser = raiser
|
|
23
|
+
|
|
24
|
+
async def __call__(self, task: Task) -> None:
|
|
25
|
+
"""Escalate `task` after the scheduler finds a cancelled dependency."""
|
|
26
|
+
transition = Transition.escalate_current(task, EscalationReason.DEPENDENCY_CANCELLED)
|
|
27
|
+
await self._raiser.raise_escalation(
|
|
28
|
+
task,
|
|
29
|
+
step=transition.step,
|
|
30
|
+
reason=EscalationReason.DEPENDENCY_CANCELLED,
|
|
31
|
+
question="A dependency was cancelled; choose whether to drop it or cancel this task.",
|
|
32
|
+
attempted="The scheduler found a cancelled dependency before dispatch.",
|
|
33
|
+
reports=(),
|
|
34
|
+
facts="",
|
|
35
|
+
view_tail="",
|
|
36
|
+
apply_state=lambda: self._writer.persist(task, transition, None),
|
|
37
|
+
)
|