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,174 @@
|
|
|
1
|
+
"""Handlers for escalation resolution options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.commands import (
|
|
8
|
+
COMMAND_ID_KEY,
|
|
9
|
+
EVENT_TEXT_KEY,
|
|
10
|
+
AcceptResolution,
|
|
11
|
+
AnswerResolution,
|
|
12
|
+
CancelResolution,
|
|
13
|
+
ResolutionKind,
|
|
14
|
+
Resolve,
|
|
15
|
+
RetryResolution,
|
|
16
|
+
)
|
|
17
|
+
from shikiru.domain.escalation import EscalationStatus, transition_for_resolution
|
|
18
|
+
from shikiru.domain.events import EventKind
|
|
19
|
+
from shikiru.domain.steps import ACTING_ROLE, Transition
|
|
20
|
+
from shikiru.domain.tasks import EscalationReason, Status
|
|
21
|
+
from shikiru.engine.handlers.base import (
|
|
22
|
+
HandlerContext,
|
|
23
|
+
command_replayed,
|
|
24
|
+
finish_task_quietly,
|
|
25
|
+
interrupt_task,
|
|
26
|
+
kept_worktree_result,
|
|
27
|
+
refresh_titles,
|
|
28
|
+
require_command_id,
|
|
29
|
+
require_task,
|
|
30
|
+
)
|
|
31
|
+
from shikiru.errors import CommandRejectedError
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from shikiru.domain.escalation import Escalation
|
|
35
|
+
from shikiru.domain.tasks import Task
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ResolveHandler:
|
|
39
|
+
"""Apply `answer`, `retry`, `accept` or `cancel` to an open escalation."""
|
|
40
|
+
|
|
41
|
+
command_type: type[Resolve] = Resolve
|
|
42
|
+
|
|
43
|
+
def __init__(self, context: HandlerContext) -> None:
|
|
44
|
+
self._context = context
|
|
45
|
+
|
|
46
|
+
async def handle(self, command: Resolve) -> dict[str, object]:
|
|
47
|
+
if command_replayed(self._context, EventKind.ESCALATION_RESOLVED, command):
|
|
48
|
+
escalation = self._escalation(command.escalation_id)
|
|
49
|
+
else:
|
|
50
|
+
escalation = self._open_escalation(command)
|
|
51
|
+
await self._apply(command, escalation)
|
|
52
|
+
task = require_task(self._context, escalation.task_id)
|
|
53
|
+
await refresh_titles(self._context, task.id)
|
|
54
|
+
kept = (
|
|
55
|
+
await finish_task_quietly(self._context, task)
|
|
56
|
+
if isinstance(command.resolution, CancelResolution)
|
|
57
|
+
else None
|
|
58
|
+
)
|
|
59
|
+
return {
|
|
60
|
+
"escalation_id": escalation.id,
|
|
61
|
+
"task_id": task.id,
|
|
62
|
+
"status": task.status.value,
|
|
63
|
+
**kept_worktree_result(kept),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async def _apply(self, command: Resolve, escalation: Escalation) -> None:
|
|
67
|
+
task = require_task(self._context, escalation.task_id)
|
|
68
|
+
if task.status is not Status.ESCALATED:
|
|
69
|
+
message = (
|
|
70
|
+
f"{escalation.id} no longer applies: {task.id} is {task.status.value}, "
|
|
71
|
+
"not escalated"
|
|
72
|
+
)
|
|
73
|
+
raise CommandRejectedError(message)
|
|
74
|
+
transition = self._transition(task, escalation, command)
|
|
75
|
+
if not isinstance(command.resolution, AnswerResolution):
|
|
76
|
+
await interrupt_task(self._context, task, even_if_inactive=True)
|
|
77
|
+
with self._context.atomically():
|
|
78
|
+
self._apply_transition(task, escalation, command, transition)
|
|
79
|
+
self._context.escalations.resolve(escalation.id, self._resolution_text(command))
|
|
80
|
+
self._context.events.append(
|
|
81
|
+
EventKind.ESCALATION_RESOLVED,
|
|
82
|
+
task_id=task.id,
|
|
83
|
+
message=f"{escalation.id} resolved with {command.resolution.kind.value}",
|
|
84
|
+
data={
|
|
85
|
+
"escalation_id": escalation.id,
|
|
86
|
+
"resolution": command.resolution.kind.value,
|
|
87
|
+
**self._answer_text(command),
|
|
88
|
+
COMMAND_ID_KEY: require_command_id(command),
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _escalation(self, escalation_id: str) -> Escalation:
|
|
93
|
+
escalation = self._context.escalations.get(escalation_id)
|
|
94
|
+
if escalation is None:
|
|
95
|
+
message = f"escalation {escalation_id} does not exist"
|
|
96
|
+
raise CommandRejectedError(message)
|
|
97
|
+
return escalation
|
|
98
|
+
|
|
99
|
+
def _open_escalation(self, command: Resolve) -> Escalation:
|
|
100
|
+
escalation = self._escalation(command.escalation_id)
|
|
101
|
+
if escalation.status is not EscalationStatus.OPEN:
|
|
102
|
+
message = f"escalation {command.escalation_id} is already resolved"
|
|
103
|
+
raise CommandRejectedError(message)
|
|
104
|
+
if command.resolution.kind not in {option.label for option in escalation.options}:
|
|
105
|
+
message = (
|
|
106
|
+
f"resolution {command.resolution.kind.value} is not valid for "
|
|
107
|
+
f"{escalation.reason.value}"
|
|
108
|
+
)
|
|
109
|
+
raise CommandRejectedError(message)
|
|
110
|
+
return escalation
|
|
111
|
+
|
|
112
|
+
def _transition(self, task: Task, escalation: Escalation, command: Resolve) -> Transition:
|
|
113
|
+
acting_role = ACTING_ROLE.get(escalation.step) if escalation.step is not None else None
|
|
114
|
+
rung = command.resolution.rung if isinstance(command.resolution, RetryResolution) else None
|
|
115
|
+
try:
|
|
116
|
+
return transition_for_resolution(
|
|
117
|
+
task,
|
|
118
|
+
escalation.reason,
|
|
119
|
+
step=escalation.step,
|
|
120
|
+
resolution=command.resolution.kind,
|
|
121
|
+
rung=rung,
|
|
122
|
+
role=acting_role,
|
|
123
|
+
)
|
|
124
|
+
except ValueError as error:
|
|
125
|
+
raise CommandRejectedError(str(error)) from error
|
|
126
|
+
|
|
127
|
+
def _apply_transition(
|
|
128
|
+
self,
|
|
129
|
+
task: Task,
|
|
130
|
+
escalation: Escalation,
|
|
131
|
+
command: Resolve,
|
|
132
|
+
transition: Transition,
|
|
133
|
+
) -> None:
|
|
134
|
+
if (
|
|
135
|
+
isinstance(command.resolution, RetryResolution)
|
|
136
|
+
and escalation.reason is EscalationReason.DEPENDENCY_CANCELLED
|
|
137
|
+
):
|
|
138
|
+
self._drop_cancelled_dependencies(task.id, task.depends_on)
|
|
139
|
+
if transition.status is Status.CANCELLED:
|
|
140
|
+
self._context.tasks.finish(
|
|
141
|
+
task.id,
|
|
142
|
+
transition,
|
|
143
|
+
EventKind.TASK_CANCELLED,
|
|
144
|
+
message=f"{task.id} cancelled",
|
|
145
|
+
data={"reason": "cancelled by operator"},
|
|
146
|
+
)
|
|
147
|
+
return
|
|
148
|
+
self._context.tasks.apply_transition(task.id, transition)
|
|
149
|
+
self._context.tasks.set_requeued(task.id)
|
|
150
|
+
|
|
151
|
+
def _drop_cancelled_dependencies(self, task_id: str, dependencies: list[str]) -> None:
|
|
152
|
+
keep: list[str] = []
|
|
153
|
+
for dependency_id in dependencies:
|
|
154
|
+
dependency = self._context.tasks.get(dependency_id)
|
|
155
|
+
if dependency is not None and dependency.status is not Status.CANCELLED:
|
|
156
|
+
keep.append(dependency_id)
|
|
157
|
+
self._context.tasks.set_depends_on(task_id, keep)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _answer_text(command: Resolve) -> dict[str, str]:
|
|
161
|
+
resolution = command.resolution
|
|
162
|
+
return {EVENT_TEXT_KEY: resolution.text} if isinstance(resolution, AnswerResolution) else {}
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _resolution_text(command: Resolve) -> str:
|
|
166
|
+
resolution = command.resolution
|
|
167
|
+
if isinstance(resolution, AnswerResolution):
|
|
168
|
+
return f"{ResolutionKind.ANSWER.value} {resolution.text}"
|
|
169
|
+
if isinstance(resolution, RetryResolution):
|
|
170
|
+
rung = resolution.rung
|
|
171
|
+
return "retry" + (f" {rung.agent}:{rung.model}" if rung else "")
|
|
172
|
+
if isinstance(resolution, AcceptResolution):
|
|
173
|
+
return "accept"
|
|
174
|
+
return "cancel"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Handlers for the Operator's direct access to a task's agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.commands import COMMAND_ID_KEY, EVENT_TEXT_KEY, HandBack, Say, TakeOver
|
|
8
|
+
from shikiru.domain.events import EventKind
|
|
9
|
+
from shikiru.engine.handlers.base import (
|
|
10
|
+
HandlerContext,
|
|
11
|
+
reject_terminal,
|
|
12
|
+
require_command_id,
|
|
13
|
+
require_task,
|
|
14
|
+
)
|
|
15
|
+
from shikiru.workspace.messages import command_message_key
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from shikiru.engine.takeover import Takeover
|
|
19
|
+
from shikiru.workspace.messages import OperatorMessages
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SayHandler:
|
|
23
|
+
"""Queue an Operator message for a role's next turn without holding the task."""
|
|
24
|
+
|
|
25
|
+
command_type: type[Say] = Say
|
|
26
|
+
|
|
27
|
+
def __init__(self, context: HandlerContext, messages: OperatorMessages) -> None:
|
|
28
|
+
self._context = context
|
|
29
|
+
self._messages = messages
|
|
30
|
+
|
|
31
|
+
async def handle(self, command: Say) -> dict[str, object]:
|
|
32
|
+
task = require_task(self._context, command.task_id)
|
|
33
|
+
reject_terminal(task)
|
|
34
|
+
command_id = require_command_id(command)
|
|
35
|
+
path = self._messages.queue(
|
|
36
|
+
task.id, command.role, command.text, command_message_key(command_id)
|
|
37
|
+
)
|
|
38
|
+
if not self._context.events.command_applied(EventKind.OPERATOR_MESSAGE, command_id):
|
|
39
|
+
self._context.events.append(
|
|
40
|
+
EventKind.OPERATOR_MESSAGE,
|
|
41
|
+
task_id=task.id,
|
|
42
|
+
message=f"{task.id} message queued for {command.role.value}",
|
|
43
|
+
data={
|
|
44
|
+
"role": command.role.value,
|
|
45
|
+
"path": str(path),
|
|
46
|
+
EVENT_TEXT_KEY: command.text,
|
|
47
|
+
COMMAND_ID_KEY: command_id,
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
return {"task_id": task.id, "role": command.role.value, "path": str(path)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TakeOverHandler:
|
|
54
|
+
"""Hand a role's session to the Operator in its pane, holding only that task."""
|
|
55
|
+
|
|
56
|
+
command_type: type[TakeOver] = TakeOver
|
|
57
|
+
|
|
58
|
+
def __init__(self, context: HandlerContext, takeover: Takeover) -> None:
|
|
59
|
+
self._context = context
|
|
60
|
+
self._takeover = takeover
|
|
61
|
+
|
|
62
|
+
async def handle(self, command: TakeOver) -> dict[str, object]:
|
|
63
|
+
task = require_task(self._context, command.task_id)
|
|
64
|
+
reject_terminal(task)
|
|
65
|
+
await self._takeover.take_over(task, command.role, require_command_id(command))
|
|
66
|
+
return {"task_id": task.id, "role": command.role.value}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HandBackHandler:
|
|
70
|
+
"""Return a taken-over role's session to the engine."""
|
|
71
|
+
|
|
72
|
+
command_type: type[HandBack] = HandBack
|
|
73
|
+
|
|
74
|
+
def __init__(self, context: HandlerContext, takeover: Takeover) -> None:
|
|
75
|
+
self._context = context
|
|
76
|
+
self._takeover = takeover
|
|
77
|
+
|
|
78
|
+
async def handle(self, command: HandBack) -> dict[str, object]:
|
|
79
|
+
task = require_task(self._context, command.task_id)
|
|
80
|
+
await self._takeover.hand_back(task, command.role, require_command_id(command))
|
|
81
|
+
return {"task_id": task.id, "role": command.role.value}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Handlers for task lifecycle and scheduling commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.commands import (
|
|
8
|
+
COMMAND_ID_KEY,
|
|
9
|
+
Cancel,
|
|
10
|
+
Pause,
|
|
11
|
+
Prioritize,
|
|
12
|
+
ResolutionKind,
|
|
13
|
+
Resume,
|
|
14
|
+
Retry,
|
|
15
|
+
SetRung,
|
|
16
|
+
Submit,
|
|
17
|
+
)
|
|
18
|
+
from shikiru.domain.events import EventKind
|
|
19
|
+
from shikiru.domain.ladder import reset_for_rung_change
|
|
20
|
+
from shikiru.domain.steps import Transition
|
|
21
|
+
from shikiru.domain.tasks import Hold, Role, Status
|
|
22
|
+
from shikiru.engine.handlers.base import (
|
|
23
|
+
HandlerContext,
|
|
24
|
+
command_replayed,
|
|
25
|
+
finish_task_quietly,
|
|
26
|
+
interrupt_task,
|
|
27
|
+
kept_worktree_result,
|
|
28
|
+
refresh_titles,
|
|
29
|
+
reject_terminal,
|
|
30
|
+
require_command_id,
|
|
31
|
+
require_task,
|
|
32
|
+
)
|
|
33
|
+
from shikiru.errors import CommandRejectedError
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubmitHandler:
|
|
37
|
+
"""Insert a submitted task and emit its queue event."""
|
|
38
|
+
|
|
39
|
+
command_type: type[Submit] = Submit
|
|
40
|
+
|
|
41
|
+
def __init__(self, context: HandlerContext) -> None:
|
|
42
|
+
self._context = context
|
|
43
|
+
|
|
44
|
+
async def handle(self, command: Submit) -> dict[str, object]:
|
|
45
|
+
task = self._context.tasks.get(command.task_id) if command.task_id is not None else None
|
|
46
|
+
if task is None:
|
|
47
|
+
with self._context.atomically():
|
|
48
|
+
task = self._context.tasks.insert_from_submit(command)
|
|
49
|
+
self._context.events.append(
|
|
50
|
+
EventKind.TASK_QUEUED,
|
|
51
|
+
task_id=task.id,
|
|
52
|
+
message=f"{task.id} queued",
|
|
53
|
+
data={"priority": task.priority},
|
|
54
|
+
)
|
|
55
|
+
return {"task_id": task.id}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PrioritizeHandler:
|
|
59
|
+
"""Change one queued task's dispatch priority."""
|
|
60
|
+
|
|
61
|
+
command_type: type[Prioritize] = Prioritize
|
|
62
|
+
|
|
63
|
+
def __init__(self, context: HandlerContext) -> None:
|
|
64
|
+
self._context = context
|
|
65
|
+
|
|
66
|
+
async def handle(self, command: Prioritize) -> dict[str, object]:
|
|
67
|
+
task = require_task(self._context, command.task_id)
|
|
68
|
+
if task.status is not Status.QUEUED:
|
|
69
|
+
message = f"{task.id} can only be prioritized while queued"
|
|
70
|
+
raise CommandRejectedError(message)
|
|
71
|
+
self._context.tasks.set_priority(task.id, command.priority)
|
|
72
|
+
return {"task_id": task.id, "priority": command.priority}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PauseHandler:
|
|
76
|
+
"""Set a task pause hold, optionally interrupting its current turn."""
|
|
77
|
+
|
|
78
|
+
command_type: type[Pause] = Pause
|
|
79
|
+
|
|
80
|
+
def __init__(self, context: HandlerContext) -> None:
|
|
81
|
+
self._context = context
|
|
82
|
+
|
|
83
|
+
async def handle(self, command: Pause) -> dict[str, object]:
|
|
84
|
+
task = require_task(self._context, command.task_id)
|
|
85
|
+
reject_terminal(task)
|
|
86
|
+
if command.now:
|
|
87
|
+
await interrupt_task(self._context, task)
|
|
88
|
+
if Hold.PAUSED not in task.holds:
|
|
89
|
+
with self._context.atomically():
|
|
90
|
+
self._context.tasks.set_holds(task.id, task.holds | {Hold.PAUSED})
|
|
91
|
+
self._context.events.append(
|
|
92
|
+
EventKind.HOLD_SET,
|
|
93
|
+
task_id=task.id,
|
|
94
|
+
message=f"{task.id} paused",
|
|
95
|
+
data={"hold": Hold.PAUSED.value, "now": command.now},
|
|
96
|
+
)
|
|
97
|
+
await refresh_titles(self._context, task.id)
|
|
98
|
+
return {"task_id": task.id, "paused": True, "now": command.now}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ResumeHandler:
|
|
102
|
+
"""Clear task holds and resume the task flow, unless a role is taken over."""
|
|
103
|
+
|
|
104
|
+
command_type: type[Resume] = Resume
|
|
105
|
+
|
|
106
|
+
def __init__(self, context: HandlerContext) -> None:
|
|
107
|
+
self._context = context
|
|
108
|
+
|
|
109
|
+
async def handle(self, command: Resume) -> dict[str, object]:
|
|
110
|
+
task = require_task(self._context, command.task_id)
|
|
111
|
+
reject_terminal(task)
|
|
112
|
+
if self._context.runs.has_taken_over(task.id):
|
|
113
|
+
message = f"{task.id} has a role taken over; hand it back before resuming"
|
|
114
|
+
raise CommandRejectedError(message)
|
|
115
|
+
holds = task.holds
|
|
116
|
+
self._context.tasks.set_holds(task.id, frozenset())
|
|
117
|
+
for hold in holds:
|
|
118
|
+
self._context.events.append(
|
|
119
|
+
EventKind.HOLD_CLEARED,
|
|
120
|
+
task_id=task.id,
|
|
121
|
+
message=f"{task.id} {hold.value} hold cleared",
|
|
122
|
+
data={"hold": hold.value},
|
|
123
|
+
)
|
|
124
|
+
if holds & {Hold.PAUSED, Hold.INTERVENTION} and task.status in (
|
|
125
|
+
Status.RUNNING,
|
|
126
|
+
Status.INTEGRATING,
|
|
127
|
+
):
|
|
128
|
+
await self._context.start_task(task.id)
|
|
129
|
+
await refresh_titles(self._context, task.id)
|
|
130
|
+
return {"task_id": task.id, "paused": False, "intervention": False}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CancelHandler:
|
|
134
|
+
"""Cancel a task, interrupting its current turn and retaining its branch."""
|
|
135
|
+
|
|
136
|
+
command_type: type[Cancel] = Cancel
|
|
137
|
+
|
|
138
|
+
def __init__(self, context: HandlerContext) -> None:
|
|
139
|
+
self._context = context
|
|
140
|
+
|
|
141
|
+
async def handle(self, command: Cancel) -> dict[str, object]:
|
|
142
|
+
task = require_task(self._context, command.task_id)
|
|
143
|
+
if not command_replayed(self._context, EventKind.TASK_CANCELLED, command):
|
|
144
|
+
reject_terminal(task)
|
|
145
|
+
await interrupt_task(self._context, task)
|
|
146
|
+
current = require_task(self._context, task.id)
|
|
147
|
+
with self._context.atomically():
|
|
148
|
+
self._context.tasks.finish(
|
|
149
|
+
task.id,
|
|
150
|
+
Transition.stay(current, step=current.step, status=Status.CANCELLED),
|
|
151
|
+
EventKind.TASK_CANCELLED,
|
|
152
|
+
message=f"{task.id} cancelled",
|
|
153
|
+
data={
|
|
154
|
+
"reason": "cancelled by operator",
|
|
155
|
+
COMMAND_ID_KEY: require_command_id(command),
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
self._context.escalations.settle_open(task.id, ResolutionKind.CANCEL.value)
|
|
159
|
+
updated = require_task(self._context, task.id)
|
|
160
|
+
await refresh_titles(self._context, updated.id)
|
|
161
|
+
kept = await finish_task_quietly(self._context, updated)
|
|
162
|
+
return {
|
|
163
|
+
"task_id": task.id,
|
|
164
|
+
"status": Status.CANCELLED.value,
|
|
165
|
+
**kept_worktree_result(kept),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class RetryHandler:
|
|
170
|
+
"""Cancel the current step and requeue it for an idempotent retry."""
|
|
171
|
+
|
|
172
|
+
command_type: type[Retry] = Retry
|
|
173
|
+
|
|
174
|
+
def __init__(self, context: HandlerContext) -> None:
|
|
175
|
+
self._context = context
|
|
176
|
+
|
|
177
|
+
async def handle(self, command: Retry) -> dict[str, object]:
|
|
178
|
+
task = require_task(self._context, command.task_id)
|
|
179
|
+
if not command_replayed(self._context, EventKind.TASK_RETRIED, command):
|
|
180
|
+
reject_terminal(task)
|
|
181
|
+
await interrupt_task(self._context, task)
|
|
182
|
+
current = require_task(self._context, task.id)
|
|
183
|
+
counters = current.counters.model_copy(update={"consults": 0, "conflict_rounds": 0})
|
|
184
|
+
transition = dataclasses.replace(
|
|
185
|
+
Transition.stay(current, step=current.step, status=Status.QUEUED),
|
|
186
|
+
counters=counters,
|
|
187
|
+
)
|
|
188
|
+
with self._context.atomically():
|
|
189
|
+
self._context.tasks.apply_transition(current.id, transition)
|
|
190
|
+
self._context.tasks.set_requeued(current.id)
|
|
191
|
+
self._context.escalations.settle_open(current.id, ResolutionKind.RETRY.value)
|
|
192
|
+
self._context.events.append(
|
|
193
|
+
EventKind.TASK_RETRIED,
|
|
194
|
+
task_id=current.id,
|
|
195
|
+
message=f"{current.id} retried",
|
|
196
|
+
data={COMMAND_ID_KEY: require_command_id(command)},
|
|
197
|
+
)
|
|
198
|
+
await refresh_titles(self._context, task.id)
|
|
199
|
+
return {"task_id": task.id, "status": require_task(self._context, task.id).status.value}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SetRungHandler:
|
|
203
|
+
"""Apply a role rung override to the next task turn."""
|
|
204
|
+
|
|
205
|
+
command_type: type[SetRung] = SetRung
|
|
206
|
+
|
|
207
|
+
def __init__(self, context: HandlerContext) -> None:
|
|
208
|
+
self._context = context
|
|
209
|
+
|
|
210
|
+
async def handle(self, command: SetRung) -> dict[str, object]:
|
|
211
|
+
task = require_task(self._context, command.task_id)
|
|
212
|
+
reject_terminal(task)
|
|
213
|
+
old = task.overrides.get(command.role)
|
|
214
|
+
if old != command.rung:
|
|
215
|
+
counters = task.counters
|
|
216
|
+
if command.role is Role.IMPLEMENTER:
|
|
217
|
+
counters = reset_for_rung_change(counters)
|
|
218
|
+
transition = dataclasses.replace(
|
|
219
|
+
Transition.stay(task, step=task.step, status=task.status),
|
|
220
|
+
counters=counters,
|
|
221
|
+
set_overrides={command.role: command.rung},
|
|
222
|
+
)
|
|
223
|
+
with self._context.atomically():
|
|
224
|
+
self._context.tasks.apply_transition(task.id, transition)
|
|
225
|
+
self._context.events.append(
|
|
226
|
+
EventKind.RUNG_CHANGED,
|
|
227
|
+
task_id=task.id,
|
|
228
|
+
message=f"{task.id} {command.role.value} rung set to {command.rung.model}",
|
|
229
|
+
data={
|
|
230
|
+
"role": command.role.value,
|
|
231
|
+
"from": old.model if old is not None else None,
|
|
232
|
+
"to": command.rung.model,
|
|
233
|
+
"operator": True,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
await refresh_titles(self._context, task.id)
|
|
237
|
+
return {"task_id": task.id, "role": command.role.value, "model": command.rung.model}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Read and select the reports recorded for a task's turns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.domain.reports import (
|
|
9
|
+
DECISION_ACCEPT,
|
|
10
|
+
ConsultAnswer,
|
|
11
|
+
FeedbackVerdict,
|
|
12
|
+
ImplementationReport,
|
|
13
|
+
PlanReport,
|
|
14
|
+
Report,
|
|
15
|
+
Verdict,
|
|
16
|
+
)
|
|
17
|
+
from shikiru.engine.integration import IntegrationMetadata
|
|
18
|
+
from shikiru.engine.turns import read_turn_report
|
|
19
|
+
from shikiru.paths import turns_dir
|
|
20
|
+
from shikiru.view_log import ordered_turn_dirs
|
|
21
|
+
from shikiru.workspace.reports import ENGINE_VERDICT_FILENAME as _ENGINE_VERDICT_FILENAME
|
|
22
|
+
from shikiru.workspace.reports import (
|
|
23
|
+
LEGACY_COMMIT_SUBJECT,
|
|
24
|
+
InvalidTurnReportError,
|
|
25
|
+
load_engine_verdict,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from shikiru.domain.tasks import Task
|
|
33
|
+
|
|
34
|
+
_LOGGER = logging.getLogger(__name__)
|
|
35
|
+
ENGINE_VERDICT_FILENAME = _ENGINE_VERDICT_FILENAME
|
|
36
|
+
FALLBACK_COMMIT_TYPE = "chore"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ReportHistory:
|
|
40
|
+
"""Load reports in turn order and derive the contexts used by task flows."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, project_root: Path) -> None:
|
|
43
|
+
"""Read reports from the project's task run directories."""
|
|
44
|
+
self._project_root = project_root
|
|
45
|
+
|
|
46
|
+
def load(self, task_id: str) -> list[tuple[str, Report]]:
|
|
47
|
+
"""Load valid reports for `task_id`, logging and omitting invalid reports."""
|
|
48
|
+
turns_root = turns_dir(self._project_root, task_id)
|
|
49
|
+
if not turns_root.is_dir():
|
|
50
|
+
return []
|
|
51
|
+
history: list[tuple[str, Report]] = []
|
|
52
|
+
for turn_dir in ordered_turn_dirs(turns_root):
|
|
53
|
+
try:
|
|
54
|
+
report = read_turn_report(turn_dir)
|
|
55
|
+
except InvalidTurnReportError:
|
|
56
|
+
_LOGGER.exception("invalid report in %s", turn_dir)
|
|
57
|
+
report = None
|
|
58
|
+
if report is not None:
|
|
59
|
+
history.append((turn_dir.name, report))
|
|
60
|
+
try:
|
|
61
|
+
engine_verdict = load_engine_verdict(turn_dir)
|
|
62
|
+
except InvalidTurnReportError:
|
|
63
|
+
_LOGGER.exception("invalid engine verdict in %s", turn_dir)
|
|
64
|
+
engine_verdict = None
|
|
65
|
+
if engine_verdict is not None:
|
|
66
|
+
history.append(
|
|
67
|
+
(
|
|
68
|
+
turn_dir.name,
|
|
69
|
+
engine_verdict,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return history
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def context(history: Sequence[tuple[str, Report]]) -> list[dict[str, str]]:
|
|
76
|
+
"""Serialize reports into the ordered prompt and escalation context."""
|
|
77
|
+
return [{"label": label, "json": report.model_dump_json()} for label, report in history]
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def latest_question(history: Sequence[tuple[str, Report]]) -> str:
|
|
81
|
+
"""Return the Implementer question behind the latest consult, if it has one.
|
|
82
|
+
|
|
83
|
+
The Leader's answers and escalations follow the question's report, so they are skipped.
|
|
84
|
+
"""
|
|
85
|
+
for _, report in reversed(history):
|
|
86
|
+
if isinstance(report, ConsultAnswer):
|
|
87
|
+
continue
|
|
88
|
+
return report.question if isinstance(report, ImplementationReport) else ""
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
def latest_report(self, task_id: str) -> Report | None:
|
|
92
|
+
"""Return the newest valid report for `task_id`."""
|
|
93
|
+
history = self.load(task_id)
|
|
94
|
+
return history[-1][1] if history else None
|
|
95
|
+
|
|
96
|
+
def integration_metadata(self, task: Task) -> IntegrationMetadata:
|
|
97
|
+
"""Choose the accepted change's subject and summary, falling back to the latest plan.
|
|
98
|
+
|
|
99
|
+
A task with neither gets a `chore` subject named after its title, summarized by it.
|
|
100
|
+
"""
|
|
101
|
+
history = self.load(task.id)
|
|
102
|
+
fallback_subject = f"{FALLBACK_COMMIT_TYPE}: {' '.join(task.title.split())}"
|
|
103
|
+
for _, report in reversed(history):
|
|
104
|
+
if (
|
|
105
|
+
isinstance(report, Verdict)
|
|
106
|
+
and not isinstance(report, FeedbackVerdict)
|
|
107
|
+
and report.decision == DECISION_ACCEPT
|
|
108
|
+
):
|
|
109
|
+
return IntegrationMetadata(
|
|
110
|
+
subject=(
|
|
111
|
+
fallback_subject
|
|
112
|
+
if report.commit_subject == LEGACY_COMMIT_SUBJECT
|
|
113
|
+
else report.commit_subject
|
|
114
|
+
),
|
|
115
|
+
summary=report.summary,
|
|
116
|
+
residual_risks=tuple(report.residual_risks),
|
|
117
|
+
)
|
|
118
|
+
for _, report in reversed(history):
|
|
119
|
+
if isinstance(report, PlanReport):
|
|
120
|
+
return IntegrationMetadata(
|
|
121
|
+
subject=(
|
|
122
|
+
fallback_subject
|
|
123
|
+
if report.commit_subject == LEGACY_COMMIT_SUBJECT
|
|
124
|
+
else report.commit_subject
|
|
125
|
+
),
|
|
126
|
+
summary=report.summary,
|
|
127
|
+
)
|
|
128
|
+
return IntegrationMetadata(subject=fallback_subject, summary=task.title)
|