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,110 @@
|
|
|
1
|
+
"""Files a task flow's escalation with its gathered evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.recovery import Failed, ProtocolError, Stalled
|
|
8
|
+
from shikiru.domain.reports import ConsultAnswer, ImplementationReport, Verdict
|
|
9
|
+
from shikiru.domain.tasks import EscalationReason, Status
|
|
10
|
+
from shikiru.errors import InvariantError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from shikiru.domain.recovery import TurnResult
|
|
14
|
+
from shikiru.domain.steps import Outcome, Transition
|
|
15
|
+
from shikiru.domain.tasks import Role, Task
|
|
16
|
+
from shikiru.engine.escalations import EscalationRaiser
|
|
17
|
+
from shikiru.engine.evidence import EvidenceCollector
|
|
18
|
+
from shikiru.engine.transitions import TransitionWriter
|
|
19
|
+
from shikiru.store.tasks import TaskStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def escalation_narrative(
|
|
23
|
+
reason: EscalationReason,
|
|
24
|
+
outcome: Outcome | None,
|
|
25
|
+
result: TurnResult | None = None,
|
|
26
|
+
) -> tuple[str, str]:
|
|
27
|
+
"""Return the default question and the attempted-recovery text of an escalation."""
|
|
28
|
+
question = "An Operator decision is needed to continue."
|
|
29
|
+
attempted = "Automatic recovery has no option left"
|
|
30
|
+
if reason is EscalationReason.ITERATIONS_EXHAUSTED:
|
|
31
|
+
question = (
|
|
32
|
+
"The final round still requested changes; give final-round guidance, accept or cancel."
|
|
33
|
+
)
|
|
34
|
+
attempted = "Implement and verify cycled to the configured iteration limit."
|
|
35
|
+
elif reason is EscalationReason.ENGINE_ERROR:
|
|
36
|
+
attempted = "The engine raised an error no automatic recovery covers."
|
|
37
|
+
elif isinstance(outcome, ConsultAnswer) and outcome.escalation is not None:
|
|
38
|
+
question = outcome.escalation.question
|
|
39
|
+
attempted = "The Leader escalated during a consult turn."
|
|
40
|
+
elif isinstance(outcome, Verdict) and outcome.escalation is not None:
|
|
41
|
+
question = outcome.escalation.question
|
|
42
|
+
attempted = "The Leader escalated during verification."
|
|
43
|
+
elif isinstance(outcome, ImplementationReport):
|
|
44
|
+
question = outcome.question
|
|
45
|
+
attempted = "The Implementer's question exceeded the consult limit."
|
|
46
|
+
elif isinstance(result, Failed):
|
|
47
|
+
question = result.message
|
|
48
|
+
attempted = f"The agent reported a {result.kind.value} failure."
|
|
49
|
+
elif isinstance(result, Stalled):
|
|
50
|
+
question = "The agent's turn stream stopped progressing."
|
|
51
|
+
attempted = "Automatic stall recovery was exhausted."
|
|
52
|
+
elif isinstance(result, ProtocolError):
|
|
53
|
+
question = "The agent did not produce a valid report."
|
|
54
|
+
attempted = "The Helper could not rebuild it."
|
|
55
|
+
elif result is not None:
|
|
56
|
+
question = "The agent session could not continue."
|
|
57
|
+
attempted = f"Recovery ended with {type(result).__name__}."
|
|
58
|
+
return question, attempted
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EscalationFiler:
|
|
62
|
+
"""Collects the evidence, then persists the escalated state and records the escalation."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
raiser: EscalationRaiser,
|
|
68
|
+
evidence: EvidenceCollector,
|
|
69
|
+
writer: TransitionWriter,
|
|
70
|
+
tasks: TaskStore,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Record escalations through `raiser` with evidence from `evidence`, state via `writer`."""
|
|
73
|
+
self._raiser = raiser
|
|
74
|
+
self._evidence = evidence
|
|
75
|
+
self._writer = writer
|
|
76
|
+
self._tasks = tasks
|
|
77
|
+
|
|
78
|
+
async def escalate(
|
|
79
|
+
self,
|
|
80
|
+
task: Task,
|
|
81
|
+
transition: Transition,
|
|
82
|
+
outcome: Outcome | None,
|
|
83
|
+
*,
|
|
84
|
+
question: str | None = None,
|
|
85
|
+
result: TurnResult | None = None,
|
|
86
|
+
role: Role | None = None,
|
|
87
|
+
) -> Task:
|
|
88
|
+
"""Apply the escalating `transition` with its packet and return the stored task."""
|
|
89
|
+
reason = transition.escalation_reason
|
|
90
|
+
if transition.status is not Status.ESCALATED or reason is None:
|
|
91
|
+
message = f"{task.id} escalation has no escalated status or reason"
|
|
92
|
+
raise InvariantError(message)
|
|
93
|
+
narrative_question, attempted = escalation_narrative(reason, outcome, result)
|
|
94
|
+
evidence = await self._evidence.collect(task, transition.step, role)
|
|
95
|
+
agent_request = (
|
|
96
|
+
outcome.escalation if isinstance(outcome, (ConsultAnswer, Verdict)) else None
|
|
97
|
+
)
|
|
98
|
+
await self._raiser.raise_escalation(
|
|
99
|
+
task,
|
|
100
|
+
step=transition.step,
|
|
101
|
+
reason=reason,
|
|
102
|
+
question=narrative_question if question is None else question,
|
|
103
|
+
attempted=attempted,
|
|
104
|
+
reports=evidence.reports,
|
|
105
|
+
facts=evidence.facts,
|
|
106
|
+
view_tail=evidence.view_tail,
|
|
107
|
+
apply_state=lambda: self._writer.persist(task, transition, outcome),
|
|
108
|
+
agent_request=agent_request,
|
|
109
|
+
)
|
|
110
|
+
return self._tasks.require(task.id)
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""`EscalationRaiser`: builds the packet and records the escalation.
|
|
2
|
+
|
|
3
|
+
The packet keeps an agent's product alternatives separate from the engine's allowed resolution
|
|
4
|
+
actions. Only the latter are accepted by the resolution command contract.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from pydantic import TypeAdapter, ValidationError
|
|
17
|
+
|
|
18
|
+
from shikiru.domain.attempts import attempt_lines, summarise_attempts
|
|
19
|
+
from shikiru.domain.escalation import (
|
|
20
|
+
ESCALATION_CAUSES,
|
|
21
|
+
RESOLUTION_CONSEQUENCES,
|
|
22
|
+
EscalationOption,
|
|
23
|
+
options_for,
|
|
24
|
+
)
|
|
25
|
+
from shikiru.domain.escalation_evidence import NO_AGENT_PROPOSAL
|
|
26
|
+
from shikiru.domain.events import EventKind
|
|
27
|
+
from shikiru.domain.reports import (
|
|
28
|
+
DECISION_ESCALATE,
|
|
29
|
+
REPORT_JSON_INDENT,
|
|
30
|
+
ConsultAnswer,
|
|
31
|
+
EscalationRequest,
|
|
32
|
+
Report,
|
|
33
|
+
Verdict,
|
|
34
|
+
)
|
|
35
|
+
from shikiru.domain.steps import Transition
|
|
36
|
+
from shikiru.domain.tasks import EscalationReason
|
|
37
|
+
from shikiru.paths import run_dir
|
|
38
|
+
from shikiru.private import make_private_dir, write_private_text
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Callable, Sequence
|
|
42
|
+
from contextlib import AbstractContextManager
|
|
43
|
+
|
|
44
|
+
from shikiru.config.settings import Settings
|
|
45
|
+
from shikiru.domain.escalation import Escalation
|
|
46
|
+
from shikiru.domain.tasks import Step, Task
|
|
47
|
+
from shikiru.engine.evidence import EvidenceCollector
|
|
48
|
+
from shikiru.engine.titles import TaskTitles
|
|
49
|
+
from shikiru.engine.transitions import TransitionWriter
|
|
50
|
+
from shikiru.store.escalations import EscalationStore
|
|
51
|
+
from shikiru.store.events import EventLog
|
|
52
|
+
from shikiru.store.runs import RunStore
|
|
53
|
+
|
|
54
|
+
ESCALATIONS_DIR_NAME = "escalations"
|
|
55
|
+
PACKET_FILENAME_SUFFIX = ".md"
|
|
56
|
+
PACKET_TEMPORARY_SUFFIX = ".tmp"
|
|
57
|
+
LOST_ESCALATION_QUESTION = (
|
|
58
|
+
"The task was escalated but its escalation record was lost; retry or cancel."
|
|
59
|
+
)
|
|
60
|
+
LOST_ESCALATION_ATTEMPTED = (
|
|
61
|
+
"The engine restarted and found the task escalated with no open escalation."
|
|
62
|
+
)
|
|
63
|
+
RESTORED_PACKET_ATTEMPTED = "The packet file was missing and was rewritten from recorded state."
|
|
64
|
+
_LOGGER = logging.getLogger(__name__)
|
|
65
|
+
_REPORT_DATA = TypeAdapter(dict[str, object])
|
|
66
|
+
_REPORT: TypeAdapter[Report] = TypeAdapter(Report)
|
|
67
|
+
_REPORT_DECISION_KEY = "decision"
|
|
68
|
+
_VERDICT_SUMMARY_KEY = "summary"
|
|
69
|
+
_CONSULT_ANSWER_KEY = "answer"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class PacketContext:
|
|
74
|
+
"""Everything `render_packet` needs to assemble one escalation packet."""
|
|
75
|
+
|
|
76
|
+
task_id: str
|
|
77
|
+
step: Step | None
|
|
78
|
+
reason: EscalationReason
|
|
79
|
+
timeline: Sequence[str]
|
|
80
|
+
attempted: str
|
|
81
|
+
reports: str
|
|
82
|
+
facts: str
|
|
83
|
+
view_tail: str
|
|
84
|
+
sessions: str
|
|
85
|
+
question: str
|
|
86
|
+
options: tuple[EscalationOption, ...]
|
|
87
|
+
agent_request: EscalationRequest | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _headline(context: PacketContext) -> str:
|
|
91
|
+
step_label = context.step.value if context.step is not None else "no step"
|
|
92
|
+
sentence = ESCALATION_CAUSES[context.reason]
|
|
93
|
+
return f"{context.task_id} · {step_label} · {context.reason.value}: {sentence}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _options_section(options: tuple[EscalationOption, ...]) -> str:
|
|
97
|
+
lines: list[str] = []
|
|
98
|
+
for option in options:
|
|
99
|
+
marker = " ★" if option.recommended else ""
|
|
100
|
+
lines.append(f"- `{option.label.value}`{marker}: {RESOLUTION_CONSEQUENCES[option.label]}")
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _agent_request_from_reports(reports: Sequence[dict[str, str]]) -> EscalationRequest | None:
|
|
105
|
+
if not reports:
|
|
106
|
+
return None
|
|
107
|
+
data = _REPORT_DATA.validate_json(reports[-1]["json"])
|
|
108
|
+
if data.get(_REPORT_DECISION_KEY) != DECISION_ESCALATE:
|
|
109
|
+
return None
|
|
110
|
+
if _VERDICT_SUMMARY_KEY in data:
|
|
111
|
+
report = Verdict.model_validate(data)
|
|
112
|
+
elif _CONSULT_ANSWER_KEY in data:
|
|
113
|
+
report = ConsultAnswer.model_validate(data)
|
|
114
|
+
else:
|
|
115
|
+
return None
|
|
116
|
+
return report.escalation
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render_packet(context: PacketContext) -> str:
|
|
120
|
+
"""Render `context` as the packet markdown.
|
|
121
|
+
|
|
122
|
+
Headline, timeline, what was tried, evidence, the Leader's question and typed agent
|
|
123
|
+
alternatives, then the engine's resolvable options. Each option set has one recommendation.
|
|
124
|
+
"""
|
|
125
|
+
timeline = "\n".join(f"- {line}" for line in context.timeline) or "(no recorded events)"
|
|
126
|
+
agent_alternatives = (
|
|
127
|
+
context.agent_request.model_dump_json(indent=REPORT_JSON_INDENT)
|
|
128
|
+
if context.agent_request
|
|
129
|
+
else NO_AGENT_PROPOSAL
|
|
130
|
+
)
|
|
131
|
+
return (
|
|
132
|
+
f"# {_headline(context)}\n\n"
|
|
133
|
+
f"## Timeline\n\n{timeline}\n\n"
|
|
134
|
+
f"## What was tried\n\n{context.attempted}\n\n"
|
|
135
|
+
f"## Evidence\n\n"
|
|
136
|
+
f"### Reports\n\n{context.reports or '(none)'}\n\n"
|
|
137
|
+
f"### Facts\n\n{context.facts or '(none)'}\n\n"
|
|
138
|
+
f"### Recent turn signals\n\n{context.view_tail or '(none)'}\n\n"
|
|
139
|
+
f"### Sessions\n\n{context.sessions or '(none)'}\n\n"
|
|
140
|
+
f"## Question\n\n{context.question}\n\n"
|
|
141
|
+
f"## Agent alternatives\n\n"
|
|
142
|
+
f"{agent_alternatives}\n\n"
|
|
143
|
+
f"## Options\n\n{_options_section(context.options)}\n"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class EscalationRaiser:
|
|
148
|
+
"""Builds the escalation packet and commits the escalated state with its row and event."""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
escalations: EscalationStore,
|
|
153
|
+
events: EventLog,
|
|
154
|
+
runs: RunStore,
|
|
155
|
+
project_root: Path,
|
|
156
|
+
*,
|
|
157
|
+
atomically: Callable[[], AbstractContextManager[object]],
|
|
158
|
+
titles: TaskTitles,
|
|
159
|
+
evidence: EvidenceCollector,
|
|
160
|
+
settings: Settings,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Persist through `escalations`/`events` in `atomically`, refresh titles via `titles`.
|
|
163
|
+
|
|
164
|
+
`evidence` supplies the recorded reports, facts and turn tail of a restored packet.
|
|
165
|
+
"""
|
|
166
|
+
self._escalations = escalations
|
|
167
|
+
self._events = events
|
|
168
|
+
self._runs = runs
|
|
169
|
+
self._project_root = project_root
|
|
170
|
+
self._atomically = atomically
|
|
171
|
+
self._titles = titles
|
|
172
|
+
self._evidence = evidence
|
|
173
|
+
self._settings = settings
|
|
174
|
+
self._unwritable: set[Path] = set()
|
|
175
|
+
|
|
176
|
+
async def raise_escalation(
|
|
177
|
+
self,
|
|
178
|
+
task: Task,
|
|
179
|
+
*,
|
|
180
|
+
step: Step | None,
|
|
181
|
+
reason: EscalationReason,
|
|
182
|
+
question: str,
|
|
183
|
+
attempted: str,
|
|
184
|
+
reports: Sequence[dict[str, str]],
|
|
185
|
+
facts: str,
|
|
186
|
+
view_tail: str,
|
|
187
|
+
apply_state: Callable[[], Task],
|
|
188
|
+
agent_request: EscalationRequest | None = None,
|
|
189
|
+
) -> Escalation:
|
|
190
|
+
"""Write the packet, then commit `apply_state`, the escalation row and its event as one.
|
|
191
|
+
|
|
192
|
+
`apply_state` persists the task's escalated state and returns the task as stored. The
|
|
193
|
+
packet file is written first and removed again if the transaction fails, so an escalated
|
|
194
|
+
task never lacks its escalation. A packet that cannot be written does not fail the
|
|
195
|
+
escalation: the row commits and `restore_packet` rewrites the file once the cause is gone.
|
|
196
|
+
Titles refresh after the commit.
|
|
197
|
+
"""
|
|
198
|
+
options = options_for(reason)
|
|
199
|
+
context = PacketContext(
|
|
200
|
+
task_id=task.id,
|
|
201
|
+
step=step,
|
|
202
|
+
reason=reason,
|
|
203
|
+
timeline=self._timeline(task.id),
|
|
204
|
+
attempted=self._attempts(task, attempted, reports, facts),
|
|
205
|
+
reports=self._reports_evidence(reports),
|
|
206
|
+
facts=facts,
|
|
207
|
+
view_tail=view_tail,
|
|
208
|
+
sessions=self._sessions_evidence(task.id),
|
|
209
|
+
question=question,
|
|
210
|
+
options=options,
|
|
211
|
+
agent_request=agent_request,
|
|
212
|
+
)
|
|
213
|
+
headline = _headline(context)
|
|
214
|
+
escalation_id = self._escalations.reserve_id()
|
|
215
|
+
packet_path = self._packet_path(task.id, escalation_id)
|
|
216
|
+
self._write_packet(task.id, packet_path, context)
|
|
217
|
+
try:
|
|
218
|
+
with self._atomically():
|
|
219
|
+
updated = apply_state()
|
|
220
|
+
escalation = self._escalations.open(
|
|
221
|
+
task.id,
|
|
222
|
+
escalation_id=escalation_id,
|
|
223
|
+
step=step,
|
|
224
|
+
reason=reason,
|
|
225
|
+
headline=headline,
|
|
226
|
+
packet_path=str(packet_path),
|
|
227
|
+
options=options,
|
|
228
|
+
)
|
|
229
|
+
self._events.append(
|
|
230
|
+
EventKind.ESCALATION,
|
|
231
|
+
task_id=task.id,
|
|
232
|
+
message=headline,
|
|
233
|
+
data={"escalation_id": escalation.id, "reason": reason.value},
|
|
234
|
+
)
|
|
235
|
+
except BaseException:
|
|
236
|
+
packet_path.unlink(missing_ok=True)
|
|
237
|
+
raise
|
|
238
|
+
await self._refresh_titles(updated)
|
|
239
|
+
return escalation
|
|
240
|
+
|
|
241
|
+
async def ensure_actionable(self, task: Task) -> None:
|
|
242
|
+
"""Give an escalated `task` an open escalation with a packet on disk.
|
|
243
|
+
|
|
244
|
+
A missing packet is rewritten from durable state at its recorded path; a task without
|
|
245
|
+
any open escalation gets a new `engine_error` one. Neither duplicates an open escalation.
|
|
246
|
+
"""
|
|
247
|
+
open_ones = [e for e in self._escalations.list_open() if e.task_id == task.id]
|
|
248
|
+
if not open_ones:
|
|
249
|
+
await self.raise_escalation(
|
|
250
|
+
task,
|
|
251
|
+
step=task.step,
|
|
252
|
+
reason=EscalationReason.ENGINE_ERROR,
|
|
253
|
+
question=LOST_ESCALATION_QUESTION,
|
|
254
|
+
attempted=LOST_ESCALATION_ATTEMPTED,
|
|
255
|
+
reports=(),
|
|
256
|
+
facts="",
|
|
257
|
+
view_tail="",
|
|
258
|
+
apply_state=lambda: task,
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
for escalation in self._without_packet(open_ones):
|
|
262
|
+
path = Path(escalation.packet_path)
|
|
263
|
+
if self._packet_directory_ready(task.id, path):
|
|
264
|
+
context = await self._restored_context(task, escalation)
|
|
265
|
+
self._write_packet(task.id, path, context)
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _without_packet(escalations: Sequence[Escalation]) -> list[Escalation]:
|
|
269
|
+
return [e for e in escalations if not Path(e.packet_path).exists()]
|
|
270
|
+
|
|
271
|
+
async def _restored_context(self, task: Task, escalation: Escalation) -> PacketContext:
|
|
272
|
+
evidence = await self._evidence.collect(task, escalation.step)
|
|
273
|
+
return PacketContext(
|
|
274
|
+
task_id=task.id,
|
|
275
|
+
step=escalation.step,
|
|
276
|
+
reason=escalation.reason,
|
|
277
|
+
timeline=self._timeline(task.id),
|
|
278
|
+
attempted=self._attempts(
|
|
279
|
+
task, RESTORED_PACKET_ATTEMPTED, evidence.reports, evidence.facts
|
|
280
|
+
),
|
|
281
|
+
reports=self._reports_evidence(evidence.reports),
|
|
282
|
+
facts=evidence.facts,
|
|
283
|
+
view_tail=evidence.view_tail,
|
|
284
|
+
sessions=self._sessions_evidence(task.id),
|
|
285
|
+
question=escalation.headline,
|
|
286
|
+
options=escalation.options,
|
|
287
|
+
agent_request=_agent_request_from_reports(evidence.reports),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _packet_directory_ready(self, task_id: str, path: Path) -> bool:
|
|
291
|
+
try:
|
|
292
|
+
make_private_dir(path.parent)
|
|
293
|
+
if not os.access(path.parent, os.W_OK | os.X_OK):
|
|
294
|
+
message = f"{path.parent} is not writable"
|
|
295
|
+
raise PermissionError(message)
|
|
296
|
+
except OSError as error:
|
|
297
|
+
self._report_unwritable(task_id, path, error)
|
|
298
|
+
return False
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
def _write_packet(self, task_id: str, path: Path, context: PacketContext) -> None:
|
|
302
|
+
try:
|
|
303
|
+
make_private_dir(path.parent)
|
|
304
|
+
temporary = path.with_name(path.name + PACKET_TEMPORARY_SUFFIX)
|
|
305
|
+
try:
|
|
306
|
+
write_private_text(temporary, render_packet(context))
|
|
307
|
+
temporary.replace(path)
|
|
308
|
+
except OSError:
|
|
309
|
+
with suppress(OSError):
|
|
310
|
+
temporary.unlink(missing_ok=True)
|
|
311
|
+
raise
|
|
312
|
+
except OSError as error:
|
|
313
|
+
self._report_unwritable(task_id, path, error)
|
|
314
|
+
else:
|
|
315
|
+
self._unwritable.discard(path)
|
|
316
|
+
|
|
317
|
+
def _report_unwritable(self, task_id: str, path: Path, error: OSError) -> None:
|
|
318
|
+
if path in self._unwritable:
|
|
319
|
+
return
|
|
320
|
+
self._unwritable.add(path)
|
|
321
|
+
_LOGGER.error("escalation packet %s could not be written", path, exc_info=error)
|
|
322
|
+
self._events.append(
|
|
323
|
+
EventKind.ENGINE_FAULT,
|
|
324
|
+
task_id=task_id,
|
|
325
|
+
message=f"escalation packet {path.name} could not be written: {error}",
|
|
326
|
+
data={"error": type(error).__name__, "recoverable": True, "packet": str(path)},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def _refresh_titles(self, task: Task) -> None:
|
|
330
|
+
try:
|
|
331
|
+
await self._titles.refresh(task)
|
|
332
|
+
except Exception:
|
|
333
|
+
_LOGGER.exception("title refresh failed for %s", task.id)
|
|
334
|
+
|
|
335
|
+
def _packet_path(self, task_id: str, escalation_id: str) -> Path:
|
|
336
|
+
return (
|
|
337
|
+
run_dir(self._project_root, task_id)
|
|
338
|
+
/ ESCALATIONS_DIR_NAME
|
|
339
|
+
/ (escalation_id + PACKET_FILENAME_SUFFIX)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def _timeline(self, task_id: str) -> list[str]:
|
|
343
|
+
events = self._events.since(0, task_id=task_id)
|
|
344
|
+
return [f"{event.ts.isoformat()} {event.kind.value}: {event.message}" for event in events]
|
|
345
|
+
|
|
346
|
+
def _attempts(
|
|
347
|
+
self, task: Task, reason: str, reports: Sequence[dict[str, str]], facts: str
|
|
348
|
+
) -> str:
|
|
349
|
+
items = summarise_attempts(
|
|
350
|
+
reason,
|
|
351
|
+
self._events.since(0, task_id=task.id),
|
|
352
|
+
self._readable_reports(reports),
|
|
353
|
+
task,
|
|
354
|
+
facts,
|
|
355
|
+
max_iterations=self._settings.orchestration.max_iterations,
|
|
356
|
+
limit=self._settings.escalation.tried_lines,
|
|
357
|
+
)
|
|
358
|
+
return attempt_lines(items)
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def _readable_reports(reports: Sequence[dict[str, str]]) -> dict[str, Report]:
|
|
362
|
+
readable: dict[str, Report] = {}
|
|
363
|
+
for report in reports:
|
|
364
|
+
with suppress(ValidationError):
|
|
365
|
+
readable[report["label"]] = _REPORT.validate_json(report["json"])
|
|
366
|
+
return readable
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _reports_evidence(reports: Sequence[dict[str, str]]) -> str:
|
|
370
|
+
return "\n\n".join(f"**{report['label']}**\n\n{report['json']}" for report in reports)
|
|
371
|
+
|
|
372
|
+
def _sessions_evidence(self, task_id: str) -> str:
|
|
373
|
+
lines: list[str] = []
|
|
374
|
+
for run in self._runs.for_task(task_id):
|
|
375
|
+
resume = run.resume_command or "(not resumable)"
|
|
376
|
+
lines.append(f"- {run.role.value}: {run.agent} session `{run.session_id}` — {resume}")
|
|
377
|
+
return "\n".join(lines)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class DispatchFailureEscalator:
|
|
381
|
+
"""Escalate a task after repeated failures while preparing its dispatch."""
|
|
382
|
+
|
|
383
|
+
def __init__(self, writer: TransitionWriter, raiser: EscalationRaiser) -> None:
|
|
384
|
+
"""Use `writer` for the state change and `raiser` for the escalation."""
|
|
385
|
+
self._writer = writer
|
|
386
|
+
self._raiser = raiser
|
|
387
|
+
|
|
388
|
+
async def __call__(self, task: Task, error: Exception) -> None:
|
|
389
|
+
"""Mark `task` escalated and record the dispatch error for the operator."""
|
|
390
|
+
transition = Transition.escalate_current(task, EscalationReason.ENGINE_ERROR)
|
|
391
|
+
await self._raiser.raise_escalation(
|
|
392
|
+
task,
|
|
393
|
+
step=transition.step,
|
|
394
|
+
reason=EscalationReason.ENGINE_ERROR,
|
|
395
|
+
question=str(error),
|
|
396
|
+
attempted="Workspace or pane setup failed after scheduler retries.",
|
|
397
|
+
reports=(),
|
|
398
|
+
facts="",
|
|
399
|
+
view_tail="",
|
|
400
|
+
apply_state=lambda: self._writer.persist(task, transition, None),
|
|
401
|
+
)
|