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,186 @@
|
|
|
1
|
+
"""Read structured decision details from Shikiru's escalation packet format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from shikiru.domain.commands import ResolutionKind
|
|
10
|
+
from shikiru.domain.reports import EscalationRequest
|
|
11
|
+
|
|
12
|
+
_QUESTION = "Question"
|
|
13
|
+
_AGENT_ALTERNATIVES = "Agent alternatives"
|
|
14
|
+
_RESOLUTIONS = "Options"
|
|
15
|
+
_TIMELINE = "Timeline"
|
|
16
|
+
_WHAT_WAS_TRIED = "What was tried"
|
|
17
|
+
_EVIDENCE = "Evidence"
|
|
18
|
+
_REPORTS = "Reports"
|
|
19
|
+
_FACTS = "Facts"
|
|
20
|
+
_RECENT_SIGNALS = "Recent turn signals"
|
|
21
|
+
_SESSIONS = "Sessions"
|
|
22
|
+
_NONE_RECORDED = "(none recorded)"
|
|
23
|
+
NO_AGENT_PROPOSAL = "No agent proposal was recorded. Engine recovery actions are listed separately."
|
|
24
|
+
_TOP_LEVEL_PREFIX = "## "
|
|
25
|
+
_SUBSECTION_PREFIX = "### "
|
|
26
|
+
_OPTION_PREFIX = "- `"
|
|
27
|
+
_OPTION_KIND_DELIMITER = "`"
|
|
28
|
+
_RECOMMENDED_MARKER = " ★"
|
|
29
|
+
_CONSEQUENCE_PREFIX = ": "
|
|
30
|
+
_EMPTY_PACKET = "escalation packet is empty"
|
|
31
|
+
_SECTION_MISSING = "escalation packet has no {heading!r} section"
|
|
32
|
+
_SECTION_AMBIGUOUS = "escalation packet has ambiguous {heading!r} section boundaries"
|
|
33
|
+
_SECTION_EMPTY = "escalation packet has an empty {heading!r} section"
|
|
34
|
+
_ALTERNATIVES_INVALID = "escalation packet has malformed 'Agent alternatives' data"
|
|
35
|
+
_OPTIONS_INVALID = "escalation packet has malformed 'Options' entry"
|
|
36
|
+
_OPTIONS_EMPTY = "escalation packet has no options"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class PacketResolution:
|
|
41
|
+
"""One engine-allowed resolution and its recorded consequences."""
|
|
42
|
+
|
|
43
|
+
kind: ResolutionKind
|
|
44
|
+
consequences: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class EscalationPacket:
|
|
49
|
+
"""Structured information recognized in the built-in escalation packet format."""
|
|
50
|
+
|
|
51
|
+
question: str | None
|
|
52
|
+
agent_request: EscalationRequest | None
|
|
53
|
+
resolutions: tuple[PacketResolution, ...]
|
|
54
|
+
evidence_sections: tuple[tuple[str, str], ...]
|
|
55
|
+
errors: tuple[str, ...]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_escalation_packet(packet: str) -> EscalationPacket:
|
|
59
|
+
"""Read the decision and evidence sections used by the built-in packet renderer."""
|
|
60
|
+
if not packet:
|
|
61
|
+
return EscalationPacket(None, None, (), (), (_EMPTY_PACKET,))
|
|
62
|
+
|
|
63
|
+
errors: list[str] = []
|
|
64
|
+
question_end = (
|
|
65
|
+
_AGENT_ALTERNATIVES if _section_exists(packet, _AGENT_ALTERNATIVES) else _RESOLUTIONS
|
|
66
|
+
)
|
|
67
|
+
question = _section(packet, _QUESTION, question_end, errors)
|
|
68
|
+
if question is None:
|
|
69
|
+
errors.append(_SECTION_MISSING.format(heading=_QUESTION))
|
|
70
|
+
elif not question:
|
|
71
|
+
errors.append(_SECTION_EMPTY.format(heading=_QUESTION))
|
|
72
|
+
question = None
|
|
73
|
+
|
|
74
|
+
agent_request = _agent_request(packet, errors)
|
|
75
|
+
resolutions = _resolutions(packet, errors)
|
|
76
|
+
evidence_sections = tuple(
|
|
77
|
+
(label, content)
|
|
78
|
+
for heading, following, label, subsection, following_subsection in (
|
|
79
|
+
(_TIMELINE, _WHAT_WAS_TRIED, _TIMELINE, False, False),
|
|
80
|
+
(_WHAT_WAS_TRIED, _EVIDENCE, _WHAT_WAS_TRIED, False, False),
|
|
81
|
+
(_REPORTS, _FACTS, _REPORTS, True, True),
|
|
82
|
+
(_FACTS, _RECENT_SIGNALS, _FACTS, True, True),
|
|
83
|
+
(_RECENT_SIGNALS, _SESSIONS, _RECENT_SIGNALS, True, True),
|
|
84
|
+
(_SESSIONS, _QUESTION, _SESSIONS, True, False),
|
|
85
|
+
)
|
|
86
|
+
if (
|
|
87
|
+
content := _section(
|
|
88
|
+
packet,
|
|
89
|
+
heading,
|
|
90
|
+
following,
|
|
91
|
+
errors,
|
|
92
|
+
subsection=subsection,
|
|
93
|
+
following_subsection=following_subsection,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
is not None
|
|
97
|
+
)
|
|
98
|
+
return EscalationPacket(
|
|
99
|
+
question,
|
|
100
|
+
agent_request,
|
|
101
|
+
resolutions,
|
|
102
|
+
evidence_sections,
|
|
103
|
+
tuple(errors),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _agent_request(packet: str, errors: list[str]) -> EscalationRequest | None:
|
|
108
|
+
if not _section_exists(packet, _AGENT_ALTERNATIVES):
|
|
109
|
+
return None
|
|
110
|
+
section = _section(packet, _AGENT_ALTERNATIVES, _RESOLUTIONS, errors)
|
|
111
|
+
if section in {_NONE_RECORDED, NO_AGENT_PROPOSAL}:
|
|
112
|
+
return None
|
|
113
|
+
if section is None:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
return EscalationRequest.model_validate_json(section)
|
|
117
|
+
except ValidationError:
|
|
118
|
+
errors.append(_ALTERNATIVES_INVALID)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolutions(packet: str, errors: list[str]) -> tuple[PacketResolution, ...]:
|
|
123
|
+
section = _section(packet, _RESOLUTIONS, None, errors)
|
|
124
|
+
if section is None:
|
|
125
|
+
return ()
|
|
126
|
+
resolutions: list[PacketResolution] = []
|
|
127
|
+
for line in section.splitlines():
|
|
128
|
+
if not line.startswith(_OPTION_PREFIX):
|
|
129
|
+
continue
|
|
130
|
+
entry = line.removeprefix(_OPTION_PREFIX)
|
|
131
|
+
kind_text, separator, details = entry.partition(_OPTION_KIND_DELIMITER)
|
|
132
|
+
if not separator:
|
|
133
|
+
errors.append(_OPTIONS_INVALID)
|
|
134
|
+
continue
|
|
135
|
+
details = details.removeprefix(_RECOMMENDED_MARKER)
|
|
136
|
+
if not details.startswith(_CONSEQUENCE_PREFIX):
|
|
137
|
+
errors.append(_OPTIONS_INVALID)
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
kind = ResolutionKind(kind_text)
|
|
141
|
+
except ValueError:
|
|
142
|
+
errors.append(_OPTIONS_INVALID)
|
|
143
|
+
continue
|
|
144
|
+
resolutions.append(PacketResolution(kind, details.removeprefix(_CONSEQUENCE_PREFIX)))
|
|
145
|
+
if not resolutions:
|
|
146
|
+
errors.append(_OPTIONS_EMPTY)
|
|
147
|
+
return tuple(resolutions)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _section_exists(packet: str, heading: str) -> bool:
|
|
151
|
+
return f"{_TOP_LEVEL_PREFIX}{heading}" in packet.splitlines()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _section(
|
|
155
|
+
packet: str,
|
|
156
|
+
heading: str,
|
|
157
|
+
following: str | None,
|
|
158
|
+
errors: list[str],
|
|
159
|
+
*,
|
|
160
|
+
subsection: bool = False,
|
|
161
|
+
following_subsection: bool | None = None,
|
|
162
|
+
) -> str | None:
|
|
163
|
+
lines = packet.splitlines()
|
|
164
|
+
prefix = _SUBSECTION_PREFIX if subsection else _TOP_LEVEL_PREFIX
|
|
165
|
+
marker = f"{prefix}{heading}"
|
|
166
|
+
matches = [index for index, line in enumerate(lines) if line == marker]
|
|
167
|
+
if not matches:
|
|
168
|
+
errors.append(_SECTION_MISSING.format(heading=heading))
|
|
169
|
+
return None
|
|
170
|
+
if len(matches) != 1:
|
|
171
|
+
errors.append(_SECTION_AMBIGUOUS.format(heading=heading))
|
|
172
|
+
return None
|
|
173
|
+
start = matches[0] + 1
|
|
174
|
+
if following is None:
|
|
175
|
+
end = len(lines)
|
|
176
|
+
else:
|
|
177
|
+
end_prefix = _SUBSECTION_PREFIX if following_subsection is True else _TOP_LEVEL_PREFIX
|
|
178
|
+
if following_subsection is None:
|
|
179
|
+
end_prefix = prefix
|
|
180
|
+
following_marker = f"{end_prefix}{following}"
|
|
181
|
+
following_matches = [index for index, line in enumerate(lines) if line == following_marker]
|
|
182
|
+
if len(following_matches) != 1 or following_matches[0] < start:
|
|
183
|
+
errors.append(_SECTION_AMBIGUOUS.format(heading=heading))
|
|
184
|
+
return None
|
|
185
|
+
end = following_matches[0]
|
|
186
|
+
return "\n".join(lines[start:end]).strip()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""The escalation resolution panel model: who, why, ordered choices and the preselection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shikiru.domain.attempts import AttemptItem, parse_attempt_lines
|
|
10
|
+
from shikiru.domain.commands import ResolutionKind
|
|
11
|
+
from shikiru.domain.escalation import (
|
|
12
|
+
ESCALATION_CAUSES,
|
|
13
|
+
RESOLUTION_CONSEQUENCES,
|
|
14
|
+
EscalationOption,
|
|
15
|
+
escalation_reason_label,
|
|
16
|
+
)
|
|
17
|
+
from shikiru.domain.tasks import DomainModel, EscalationReason
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from shikiru.domain.escalation import Escalation
|
|
21
|
+
from shikiru.domain.escalation_evidence import EscalationPacket
|
|
22
|
+
|
|
23
|
+
_WHAT_WAS_TRIED = "What was tried"
|
|
24
|
+
_ENGINE_ORDER = (ResolutionKind.ACCEPT, ResolutionKind.RETRY, ResolutionKind.CANCEL)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ChoiceKind(StrEnum):
|
|
28
|
+
"""What sending a choice does."""
|
|
29
|
+
|
|
30
|
+
ANSWER_OPTION = auto()
|
|
31
|
+
OWN_ANSWER = auto()
|
|
32
|
+
ACCEPT = auto()
|
|
33
|
+
RETRY = auto()
|
|
34
|
+
CANCEL = auto()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_CHOICE_BY_RESOLUTION = {
|
|
38
|
+
ResolutionKind.ANSWER: ChoiceKind.OWN_ANSWER,
|
|
39
|
+
ResolutionKind.ACCEPT: ChoiceKind.ACCEPT,
|
|
40
|
+
ResolutionKind.RETRY: ChoiceKind.RETRY,
|
|
41
|
+
ResolutionKind.CANCEL: ChoiceKind.CANCEL,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PanelChoice(DomainModel):
|
|
46
|
+
"""One selectable row; `label` is set only for a Leader alternative."""
|
|
47
|
+
|
|
48
|
+
kind: ChoiceKind
|
|
49
|
+
consequence: str
|
|
50
|
+
recommended: bool = False
|
|
51
|
+
label: str | None = None
|
|
52
|
+
needs_input: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ResolutionPanel(DomainModel):
|
|
56
|
+
"""Everything the panel shows for one escalation; age is derived from `created_at`."""
|
|
57
|
+
|
|
58
|
+
escalation_id: str
|
|
59
|
+
task_id: str
|
|
60
|
+
task_title: str
|
|
61
|
+
reason: EscalationReason
|
|
62
|
+
reason_label: str
|
|
63
|
+
cause: str | None
|
|
64
|
+
question: str | None
|
|
65
|
+
created_at: datetime
|
|
66
|
+
choices: tuple[PanelChoice, ...]
|
|
67
|
+
preselected: int
|
|
68
|
+
details: tuple[AttemptItem, ...]
|
|
69
|
+
disabled_reason: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_resolution_panel(
|
|
73
|
+
escalation: Escalation,
|
|
74
|
+
packet: EscalationPacket | None,
|
|
75
|
+
task_title: str,
|
|
76
|
+
disabled_reason: str | None,
|
|
77
|
+
) -> ResolutionPanel:
|
|
78
|
+
"""Build the panel for `escalation`; `packet` is `None` when it is missing or unreadable."""
|
|
79
|
+
answerable = any(option.label is ResolutionKind.ANSWER for option in escalation.options)
|
|
80
|
+
request = packet.agent_request if packet is not None and answerable else None
|
|
81
|
+
choices = (
|
|
82
|
+
*(
|
|
83
|
+
PanelChoice(
|
|
84
|
+
kind=ChoiceKind.ANSWER_OPTION,
|
|
85
|
+
consequence=option.consequences,
|
|
86
|
+
recommended=option.recommended,
|
|
87
|
+
label=option.label,
|
|
88
|
+
needs_input=option.needs_input,
|
|
89
|
+
)
|
|
90
|
+
for option in (request.options if request else ())
|
|
91
|
+
),
|
|
92
|
+
*_engine_choices(escalation.options),
|
|
93
|
+
)
|
|
94
|
+
return ResolutionPanel(
|
|
95
|
+
escalation_id=escalation.id,
|
|
96
|
+
task_id=escalation.task_id,
|
|
97
|
+
task_title=task_title,
|
|
98
|
+
reason=escalation.reason,
|
|
99
|
+
reason_label=escalation_reason_label(escalation.reason),
|
|
100
|
+
cause=None
|
|
101
|
+
if escalation.reason is EscalationReason.DECISION_REQUIRED
|
|
102
|
+
else ESCALATION_CAUSES[escalation.reason],
|
|
103
|
+
question=_question(packet, request.question if request else None) if answerable else None,
|
|
104
|
+
created_at=escalation.created_at,
|
|
105
|
+
choices=choices,
|
|
106
|
+
preselected=_preselected(choices),
|
|
107
|
+
details=_details(packet),
|
|
108
|
+
disabled_reason=disabled_reason,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _engine_choices(options: tuple[EscalationOption, ...]) -> tuple[PanelChoice, ...]:
|
|
113
|
+
by_kind = {option.label: option for option in options}
|
|
114
|
+
ordered = (ResolutionKind.ANSWER, *_ENGINE_ORDER)
|
|
115
|
+
return tuple(
|
|
116
|
+
PanelChoice(
|
|
117
|
+
kind=_CHOICE_BY_RESOLUTION[kind],
|
|
118
|
+
consequence=RESOLUTION_CONSEQUENCES[kind],
|
|
119
|
+
recommended=by_kind[kind].recommended,
|
|
120
|
+
)
|
|
121
|
+
for kind in ordered
|
|
122
|
+
if kind in by_kind
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _question(packet: EscalationPacket | None, request_question: str | None) -> str | None:
|
|
127
|
+
return request_question or (packet.question if packet is not None else None)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _preselected(choices: tuple[PanelChoice, ...]) -> int:
|
|
131
|
+
for wanted in (ChoiceKind.ANSWER_OPTION, None):
|
|
132
|
+
for index, choice in enumerate(choices):
|
|
133
|
+
if choice.recommended and (wanted is None or choice.kind is wanted):
|
|
134
|
+
return index
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _details(packet: EscalationPacket | None) -> tuple[AttemptItem, ...]:
|
|
139
|
+
if packet is None:
|
|
140
|
+
return ()
|
|
141
|
+
for label, content in packet.evidence_sections:
|
|
142
|
+
if label == _WHAT_WAS_TRIED:
|
|
143
|
+
return parse_attempt_lines(content)
|
|
144
|
+
return ()
|
shikiru/domain/events.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""`EventKind`, the closed set of event kinds written to `events`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from shikiru.domain.tasks import DomainModel
|
|
12
|
+
|
|
13
|
+
STOP_REASON_KEY = "reason"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventKind(StrEnum):
|
|
17
|
+
"""Every event kind the engine can emit."""
|
|
18
|
+
|
|
19
|
+
TASK_QUEUED = auto()
|
|
20
|
+
TASK_STARTED = auto()
|
|
21
|
+
DISPATCH_FAILED = auto()
|
|
22
|
+
STEP_STARTED = auto()
|
|
23
|
+
TURN_STARTED = auto()
|
|
24
|
+
TURN_COMPLETED = auto()
|
|
25
|
+
TURN_FAILED = auto()
|
|
26
|
+
TURN_STOPPED = auto()
|
|
27
|
+
NUDGE = auto()
|
|
28
|
+
HELPER_ACTION = auto()
|
|
29
|
+
RUNG_CHANGED = auto()
|
|
30
|
+
COMPACTED = auto()
|
|
31
|
+
QUOTA_BLOCKED = auto()
|
|
32
|
+
QUOTA_CLEARED = auto()
|
|
33
|
+
VERDICT = auto()
|
|
34
|
+
SCOPE_CHANGE = auto()
|
|
35
|
+
HOLD_SET = auto()
|
|
36
|
+
HOLD_CLEARED = auto()
|
|
37
|
+
TAKEN_OVER = auto()
|
|
38
|
+
HANDED_BACK = auto()
|
|
39
|
+
TAKEOVER_LOST = auto()
|
|
40
|
+
OPERATOR_MESSAGE = auto()
|
|
41
|
+
ESCALATION = auto()
|
|
42
|
+
ESCALATION_RESOLVED = auto()
|
|
43
|
+
INTEGRATION_STARTED = auto()
|
|
44
|
+
INTEGRATION_APPLYING = auto()
|
|
45
|
+
INTEGRATION_WAITING = auto()
|
|
46
|
+
PR_OPENED = auto()
|
|
47
|
+
PR_WITHDRAWN = auto()
|
|
48
|
+
FEEDBACK_STARTED = auto()
|
|
49
|
+
FEEDBACK_REBASE_STARTED = auto()
|
|
50
|
+
FEEDBACK_PREPARED = auto()
|
|
51
|
+
FEEDBACK_PUBLISHED = auto()
|
|
52
|
+
FEEDBACK_HANDLED = auto()
|
|
53
|
+
TASK_DONE = auto()
|
|
54
|
+
TASK_RETRIED = auto()
|
|
55
|
+
TASK_CANCELLED = auto()
|
|
56
|
+
PANE_RESTORED = auto()
|
|
57
|
+
ENGINE_STARTED = auto()
|
|
58
|
+
ENGINE_STOPPED = auto()
|
|
59
|
+
ENGINE_FAULT = auto()
|
|
60
|
+
ENGINE_RECOVERED = auto()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Event(DomainModel):
|
|
64
|
+
"""One recorded event (mirrors the `events` table)."""
|
|
65
|
+
|
|
66
|
+
id: int
|
|
67
|
+
ts: datetime
|
|
68
|
+
task_id: str | None = None
|
|
69
|
+
kind: EventKind
|
|
70
|
+
message: str
|
|
71
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Versioned Markdown and JSON rendering for a task evidence export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
|
|
11
|
+
EVIDENCE_EXPORT_FORMAT = "shikiru.task-evidence"
|
|
12
|
+
EVIDENCE_EXPORT_VERSION = 1
|
|
13
|
+
JSON_INDENT = 2
|
|
14
|
+
MINIMUM_MARKDOWN_FENCE = 3
|
|
15
|
+
MARKDOWN_NEWLINE = "\n"
|
|
16
|
+
MARKDOWN_FENCE_CHARACTER = "`"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_evidence_json(document: Mapping[str, object]) -> str:
|
|
20
|
+
"""Render a JSON-safe task evidence document with stable, readable indentation."""
|
|
21
|
+
return f"{json.dumps(document, ensure_ascii=False, indent=JSON_INDENT, allow_nan=False)}\n"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_evidence_markdown(document: Mapping[str, object]) -> str:
|
|
25
|
+
"""Render an evidence document while keeping recorded text inside safe code fences."""
|
|
26
|
+
lines = [
|
|
27
|
+
"# Shikiru task evidence export",
|
|
28
|
+
"",
|
|
29
|
+
"## Export metadata",
|
|
30
|
+
_code_block(document.get("metadata")),
|
|
31
|
+
"",
|
|
32
|
+
"## Request",
|
|
33
|
+
_code_block(document.get("request")),
|
|
34
|
+
"",
|
|
35
|
+
"## Recorded outcome",
|
|
36
|
+
_code_block(document.get("outcome")),
|
|
37
|
+
"",
|
|
38
|
+
"## Task and rung metadata",
|
|
39
|
+
_code_block(document.get("task")),
|
|
40
|
+
"",
|
|
41
|
+
"## Relationships",
|
|
42
|
+
_code_block(document.get("relationships")),
|
|
43
|
+
"",
|
|
44
|
+
"## Validated reports and verdicts",
|
|
45
|
+
_code_block(document.get("reports")),
|
|
46
|
+
"",
|
|
47
|
+
"## Recorded git facts",
|
|
48
|
+
_code_block(document.get("facts")),
|
|
49
|
+
"",
|
|
50
|
+
"## Recorded timeline",
|
|
51
|
+
_code_block(document.get("timeline")),
|
|
52
|
+
"",
|
|
53
|
+
"## Recorded sessions",
|
|
54
|
+
_code_block(document.get("sessions")),
|
|
55
|
+
"",
|
|
56
|
+
"## Task usage summary",
|
|
57
|
+
_code_block(document.get("usage")),
|
|
58
|
+
"",
|
|
59
|
+
"## Unavailable sections",
|
|
60
|
+
_code_block(document.get("unavailable_sections")),
|
|
61
|
+
"",
|
|
62
|
+
(
|
|
63
|
+
"This export contains retained Shikiru evidence. It is not a complete agent transcript "
|
|
64
|
+
"and does not recreate a removed worktree."
|
|
65
|
+
),
|
|
66
|
+
"",
|
|
67
|
+
]
|
|
68
|
+
return MARKDOWN_NEWLINE.join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _code_block(value: object) -> str:
|
|
72
|
+
content = json.dumps(value, ensure_ascii=False, indent=JSON_INDENT, allow_nan=False)
|
|
73
|
+
fence = MARKDOWN_FENCE_CHARACTER * max(
|
|
74
|
+
MINIMUM_MARKDOWN_FENCE,
|
|
75
|
+
_longest_backtick_run(content) + 1,
|
|
76
|
+
)
|
|
77
|
+
return f"{fence}json\n{content}\n{fence}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _longest_backtick_run(value: str) -> int:
|
|
81
|
+
longest = 0
|
|
82
|
+
current = 0
|
|
83
|
+
for character in value:
|
|
84
|
+
if character == MARKDOWN_FENCE_CHARACTER:
|
|
85
|
+
current += 1
|
|
86
|
+
longest = max(longest, current)
|
|
87
|
+
else:
|
|
88
|
+
current = 0
|
|
89
|
+
return longest
|
shikiru/domain/faults.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Open engine fault streaks derived from `engine_fault` and `engine_recovered` events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.domain.events import Event, EventKind
|
|
9
|
+
from shikiru.domain.tasks import DomainModel
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
SCOPE_KEY = "scope"
|
|
15
|
+
ERROR_KEY = "error"
|
|
16
|
+
RECOVERABLE_KEY = "recoverable"
|
|
17
|
+
REPEAT_KEY = "repeat"
|
|
18
|
+
DETAIL_KEY = "detail"
|
|
19
|
+
COMMAND_KEY = "command"
|
|
20
|
+
EXIT_CODE_KEY = "exit_code"
|
|
21
|
+
STDERR_KEY = "stderr"
|
|
22
|
+
TRACEBACK_KEY = "traceback"
|
|
23
|
+
_BACKOFF_FACTOR = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FaultStreak(DomainModel):
|
|
27
|
+
"""A fault that is still failing: when it began, was last recorded and how often it failed."""
|
|
28
|
+
|
|
29
|
+
scope: str
|
|
30
|
+
task_id: str | None
|
|
31
|
+
error: str
|
|
32
|
+
recoverable: bool
|
|
33
|
+
first_seen: datetime
|
|
34
|
+
last_seen: datetime
|
|
35
|
+
repeat: int
|
|
36
|
+
event_id: int
|
|
37
|
+
message: str
|
|
38
|
+
detail: str | None = None
|
|
39
|
+
command: tuple[str, ...] | None = None
|
|
40
|
+
exit_code: int | None = None
|
|
41
|
+
stderr: str | None = None
|
|
42
|
+
traceback: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def open_fault_streaks(events: Iterable[Event]) -> list[FaultStreak]:
|
|
46
|
+
"""Fold chronological fault and recovery `events` into the streaks that were never recovered.
|
|
47
|
+
|
|
48
|
+
A fault without a `scope` is a one-off record, not a streak, and is ignored. A streak keeps
|
|
49
|
+
the evidence of its latest record; evidence that record did not carry stays `None`.
|
|
50
|
+
"""
|
|
51
|
+
open_streaks: dict[tuple[str, str | None], FaultStreak] = {}
|
|
52
|
+
for event in events:
|
|
53
|
+
scope = event.data.get(SCOPE_KEY)
|
|
54
|
+
if scope is None:
|
|
55
|
+
continue
|
|
56
|
+
key = (scope, event.task_id)
|
|
57
|
+
if event.kind is EventKind.ENGINE_RECOVERED:
|
|
58
|
+
open_streaks.pop(key, None)
|
|
59
|
+
elif event.kind is EventKind.ENGINE_FAULT:
|
|
60
|
+
previous = open_streaks.get(key)
|
|
61
|
+
open_streaks[key] = FaultStreak(
|
|
62
|
+
scope=scope,
|
|
63
|
+
task_id=event.task_id,
|
|
64
|
+
error=event.data[ERROR_KEY],
|
|
65
|
+
recoverable=event.data[RECOVERABLE_KEY],
|
|
66
|
+
first_seen=previous.first_seen if previous else event.ts,
|
|
67
|
+
last_seen=event.ts,
|
|
68
|
+
repeat=(previous.repeat if previous else 0) + event.data[REPEAT_KEY],
|
|
69
|
+
event_id=event.id,
|
|
70
|
+
message=event.message,
|
|
71
|
+
detail=event.data.get(DETAIL_KEY),
|
|
72
|
+
command=_command(event.data.get(COMMAND_KEY)),
|
|
73
|
+
exit_code=event.data.get(EXIT_CODE_KEY),
|
|
74
|
+
stderr=event.data.get(STDERR_KEY),
|
|
75
|
+
traceback=event.data.get(TRACEBACK_KEY),
|
|
76
|
+
)
|
|
77
|
+
return list(open_streaks.values())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _command(recorded: list[str] | None) -> tuple[str, ...] | None:
|
|
81
|
+
return None if recorded is None else tuple(recorded)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def next_backoff(previous: float, *, initial: float, maximum: float) -> float:
|
|
85
|
+
"""Return the retry delay after `previous` (0 before the first failure): doubled, capped."""
|
|
86
|
+
return initial if previous <= 0 else min(previous * _BACKOFF_FACTOR, maximum)
|