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,337 @@
|
|
|
1
|
+
"""Run the agent turns needed to resolve integration conflicts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.agents.contracts import role_dir
|
|
9
|
+
from shikiru.domain.commands import ResolutionKind
|
|
10
|
+
from shikiru.domain.events import EventKind
|
|
11
|
+
from shikiru.domain.recovery import TurnCounters
|
|
12
|
+
from shikiru.domain.reports import (
|
|
13
|
+
CURRENT_TURN_LINK_NAME,
|
|
14
|
+
DECISION_ACCEPT,
|
|
15
|
+
DECISION_CHANGES_REQUESTED,
|
|
16
|
+
FEEDBACK_REPORT_KINDS,
|
|
17
|
+
STATUS_COMPLETED,
|
|
18
|
+
ConsultAnswer,
|
|
19
|
+
FeedbackRework,
|
|
20
|
+
ImplementationReport,
|
|
21
|
+
ReportKind,
|
|
22
|
+
Verdict,
|
|
23
|
+
)
|
|
24
|
+
from shikiru.domain.steps import CONFLICT_ROUNDS, Transition
|
|
25
|
+
from shikiru.domain.tasks import EscalationReason, Role, Status, Step, Task
|
|
26
|
+
from shikiru.engine.events import REPORT_KIND_FIELD
|
|
27
|
+
from shikiru.engine.integration import (
|
|
28
|
+
ConflictRecovery,
|
|
29
|
+
FeedbackPending,
|
|
30
|
+
IntegrationBlocked,
|
|
31
|
+
IntegrationConflict,
|
|
32
|
+
IntegrationCoordinator,
|
|
33
|
+
IntegrationHeld,
|
|
34
|
+
IntegrationMetadata,
|
|
35
|
+
OffTaskBranch,
|
|
36
|
+
PullRequestClosed,
|
|
37
|
+
RebaseConflict,
|
|
38
|
+
UncommittedWork,
|
|
39
|
+
)
|
|
40
|
+
from shikiru.engine.step_runner import TurnStopped
|
|
41
|
+
from shikiru.errors import InvariantError
|
|
42
|
+
from shikiru.paths import run_dir, turns_dir
|
|
43
|
+
from shikiru.prompts.renderer import PromptName
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Awaitable, Callable
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
from shikiru.domain.events import Event
|
|
50
|
+
from shikiru.engine.escalation_filing import EscalationFiler
|
|
51
|
+
from shikiru.engine.feedback import FeedbackRounds
|
|
52
|
+
from shikiru.engine.history import ReportHistory
|
|
53
|
+
from shikiru.engine.step_runner import StepRunner
|
|
54
|
+
from shikiru.store.events import EventLog
|
|
55
|
+
from shikiru.store.tasks import TaskStore
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_feedback_turn(event: Event) -> bool:
|
|
59
|
+
return event.data.get(REPORT_KIND_FIELD) in {kind.value for kind in FEEDBACK_REPORT_KINDS}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class IntegrationTurns:
|
|
63
|
+
"""Coordinate conflict, consult and post-rebase verify turns."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
coordinator: IntegrationCoordinator,
|
|
69
|
+
events: EventLog,
|
|
70
|
+
history: ReportHistory,
|
|
71
|
+
project_root: Path,
|
|
72
|
+
tasks: TaskStore,
|
|
73
|
+
steps: StepRunner,
|
|
74
|
+
escalations: EscalationFiler,
|
|
75
|
+
feedback: FeedbackRounds | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Coordinate the integration protocol, running its turns through `steps`.
|
|
78
|
+
|
|
79
|
+
With `feedback`, a pull request's review rounds run here before it waits again.
|
|
80
|
+
"""
|
|
81
|
+
self._coordinator = coordinator
|
|
82
|
+
self._events = events
|
|
83
|
+
self._history = history
|
|
84
|
+
self._project_root = project_root
|
|
85
|
+
self._tasks = tasks
|
|
86
|
+
self._steps = steps
|
|
87
|
+
self._escalations = escalations
|
|
88
|
+
self._feedback = feedback
|
|
89
|
+
|
|
90
|
+
async def run(self, task: Task) -> None:
|
|
91
|
+
"""Run integration and its conflict turns, then escalate blocked integration.
|
|
92
|
+
|
|
93
|
+
Resolved conflicts whose post-rebase verify never ran (the flow was interrupted) are
|
|
94
|
+
verified before integrating, so no resolution merges unreviewed.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
async def resolve_conflicts(
|
|
98
|
+
current: Task,
|
|
99
|
+
continue_rebase: Callable[[], Awaitable[None]],
|
|
100
|
+
recovery: ConflictRecovery,
|
|
101
|
+
) -> tuple[Task, IntegrationMetadata]:
|
|
102
|
+
resolved = await self._resolve_conflicts(current, continue_rebase, recovery)
|
|
103
|
+
return resolved, self._history.integration_metadata(resolved)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
metadata = self._history.integration_metadata(task)
|
|
107
|
+
current = task
|
|
108
|
+
if self._feedback is not None and self._feedback.pending(task.id):
|
|
109
|
+
if task.slot is None:
|
|
110
|
+
self._queue(task)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
async def prepare(candidate: Task) -> Task:
|
|
114
|
+
prepared, _ = await self._coordinator.prepare_publication(
|
|
115
|
+
candidate, resolve_conflicts, metadata, self._conflict_recovery
|
|
116
|
+
)
|
|
117
|
+
return prepared
|
|
118
|
+
|
|
119
|
+
await self._feedback.run(task, prepare)
|
|
120
|
+
current = self._tasks.require(task.id)
|
|
121
|
+
await self._coordinator.run(
|
|
122
|
+
current, resolve_conflicts, metadata, self._conflict_recovery
|
|
123
|
+
)
|
|
124
|
+
except FeedbackPending:
|
|
125
|
+
self._queue(task)
|
|
126
|
+
except TurnStopped, IntegrationHeld:
|
|
127
|
+
return
|
|
128
|
+
except (
|
|
129
|
+
IntegrationBlocked,
|
|
130
|
+
IntegrationConflict,
|
|
131
|
+
OffTaskBranch,
|
|
132
|
+
PullRequestClosed,
|
|
133
|
+
UncommittedWork,
|
|
134
|
+
) as error:
|
|
135
|
+
current = self._tasks.require(task.id)
|
|
136
|
+
transition = Transition.escalate(current, error.reason, step=Step.INTEGRATE)
|
|
137
|
+
await self._escalations.escalate(current, transition, None, question=str(error))
|
|
138
|
+
|
|
139
|
+
def _queue(self, task: Task) -> None:
|
|
140
|
+
current = self._tasks.require(task.id)
|
|
141
|
+
self._tasks.apply_transition(
|
|
142
|
+
task.id, Transition.stay(current, step=Step.INTEGRATE, status=Status.QUEUED)
|
|
143
|
+
)
|
|
144
|
+
self._tasks.set_slot(task.id, None)
|
|
145
|
+
self._tasks.set_wait_reason(task.id, None)
|
|
146
|
+
|
|
147
|
+
def _conflict_recovery(self, task: Task) -> ConflictRecovery:
|
|
148
|
+
history = self._history.load(task.id)
|
|
149
|
+
events = self._events.since(
|
|
150
|
+
0,
|
|
151
|
+
task_id=task.id,
|
|
152
|
+
kinds=(
|
|
153
|
+
EventKind.TURN_STARTED,
|
|
154
|
+
EventKind.TURN_COMPLETED,
|
|
155
|
+
EventKind.INTEGRATION_STARTED,
|
|
156
|
+
EventKind.FEEDBACK_REBASE_STARTED,
|
|
157
|
+
EventKind.ESCALATION,
|
|
158
|
+
EventKind.ESCALATION_RESOLVED,
|
|
159
|
+
EventKind.TASK_RETRIED,
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
events = [event for event in events if not _is_feedback_turn(event)]
|
|
163
|
+
implementer_turns = [
|
|
164
|
+
event
|
|
165
|
+
for event in events
|
|
166
|
+
if event.kind is EventKind.TURN_COMPLETED
|
|
167
|
+
and event.data.get("step") == Step.INTEGRATE.value
|
|
168
|
+
and event.data.get("role") == Role.IMPLEMENTER.value
|
|
169
|
+
and event.data.get("outcome") == STATUS_COMPLETED
|
|
170
|
+
]
|
|
171
|
+
turn_starts = [
|
|
172
|
+
event
|
|
173
|
+
for event in events
|
|
174
|
+
if event.kind is EventKind.TURN_STARTED
|
|
175
|
+
and event.data.get("step") == Step.INTEGRATE.value
|
|
176
|
+
and event.data.get("role") == Role.IMPLEMENTER.value
|
|
177
|
+
]
|
|
178
|
+
escalations = {
|
|
179
|
+
event.data.get("escalation_id"): event
|
|
180
|
+
for event in events
|
|
181
|
+
if event.kind is EventKind.ESCALATION
|
|
182
|
+
}
|
|
183
|
+
retry_events = [
|
|
184
|
+
event
|
|
185
|
+
for event in events
|
|
186
|
+
if event.kind is EventKind.TASK_RETRIED
|
|
187
|
+
or (
|
|
188
|
+
event.kind is EventKind.ESCALATION_RESOLVED
|
|
189
|
+
and event.data.get("resolution") == ResolutionKind.RETRY.value
|
|
190
|
+
and (escalation := escalations.get(event.data.get("escalation_id"))) is not None
|
|
191
|
+
and escalation.data.get("reason")
|
|
192
|
+
in {
|
|
193
|
+
EscalationReason.INTEGRATION_BLOCKED.value,
|
|
194
|
+
EscalationReason.INTEGRATION_CONFLICT.value,
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
]
|
|
198
|
+
reset_events = [
|
|
199
|
+
event
|
|
200
|
+
for event in events
|
|
201
|
+
if event.kind in (EventKind.INTEGRATION_STARTED, EventKind.FEEDBACK_REBASE_STARTED)
|
|
202
|
+
or (
|
|
203
|
+
event.kind is EventKind.TURN_COMPLETED
|
|
204
|
+
and event.data.get("step") == Step.INTEGRATE.value
|
|
205
|
+
and event.data.get("role") == Role.LEADER.value
|
|
206
|
+
and event.data.get("outcome") == DECISION_CHANGES_REQUESTED
|
|
207
|
+
)
|
|
208
|
+
] + retry_events
|
|
209
|
+
latest_reset = max((event.id for event in reset_events), default=0)
|
|
210
|
+
completed_turns = sum(event.id > latest_reset for event in implementer_turns)
|
|
211
|
+
recovery = ConflictRecovery(completed_turns=completed_turns)
|
|
212
|
+
if task.step is not Step.INTEGRATE or not history:
|
|
213
|
+
return recovery
|
|
214
|
+
label, report = history[-1]
|
|
215
|
+
if (
|
|
216
|
+
not isinstance(report, ImplementationReport)
|
|
217
|
+
or isinstance(report, FeedbackRework)
|
|
218
|
+
or report.status != STATUS_COMPLETED
|
|
219
|
+
):
|
|
220
|
+
return recovery
|
|
221
|
+
completed = [event for event in implementer_turns if event.data.get("turn") == label]
|
|
222
|
+
started = [event for event in turn_starts if event.data.get("turn") == label]
|
|
223
|
+
if not completed and not started:
|
|
224
|
+
return recovery
|
|
225
|
+
report_event = completed[-1] if completed else started[-1]
|
|
226
|
+
latest_start = max(
|
|
227
|
+
(
|
|
228
|
+
event.id
|
|
229
|
+
for event in events
|
|
230
|
+
if event.kind in (EventKind.INTEGRATION_STARTED, EventKind.FEEDBACK_REBASE_STARTED)
|
|
231
|
+
),
|
|
232
|
+
default=0,
|
|
233
|
+
)
|
|
234
|
+
later_turn_started = any(event.id > report_event.id for event in turn_starts)
|
|
235
|
+
if completed and not later_turn_started:
|
|
236
|
+
recovery = replace(recovery, force_new_turn=True)
|
|
237
|
+
if (
|
|
238
|
+
report_event.id > latest_start
|
|
239
|
+
and (completed or self._current_turn_is(task, label))
|
|
240
|
+
and not later_turn_started
|
|
241
|
+
):
|
|
242
|
+
return replace(recovery, resumed_report=True)
|
|
243
|
+
retry_after_report = any(
|
|
244
|
+
report_event.id < event.id
|
|
245
|
+
and event.id > latest_start
|
|
246
|
+
and not any(start.id > event.id for start in turn_starts)
|
|
247
|
+
for event in retry_events
|
|
248
|
+
)
|
|
249
|
+
return replace(recovery, resumed_report=retry_after_report)
|
|
250
|
+
|
|
251
|
+
def _current_turn_is(self, task: Task, label: str) -> bool:
|
|
252
|
+
current_turn = (
|
|
253
|
+
role_dir(run_dir(self._project_root, task.id), Role.IMPLEMENTER.value)
|
|
254
|
+
/ CURRENT_TURN_LINK_NAME
|
|
255
|
+
)
|
|
256
|
+
report_turn = turns_dir(self._project_root, task.id) / label
|
|
257
|
+
return current_turn.is_symlink() and current_turn.resolve() == report_turn.resolve()
|
|
258
|
+
|
|
259
|
+
async def _resolve_conflicts(
|
|
260
|
+
self,
|
|
261
|
+
task: Task,
|
|
262
|
+
continue_rebase: Callable[[], Awaitable[None]],
|
|
263
|
+
recovery: ConflictRecovery,
|
|
264
|
+
) -> Task:
|
|
265
|
+
current = task
|
|
266
|
+
context = ""
|
|
267
|
+
conflict_turns = max(recovery.completed_turns, int(recovery.resumed_report))
|
|
268
|
+
resolved = recovery.resumed_report
|
|
269
|
+
while True:
|
|
270
|
+
if not resolved:
|
|
271
|
+
if conflict_turns >= CONFLICT_ROUNDS:
|
|
272
|
+
raise IntegrationConflict.unresolved_rebase(current.id, conflict_turns, context)
|
|
273
|
+
current, context, resolved = await self._conflict_turn(
|
|
274
|
+
current, context, recovery.force_new_turn
|
|
275
|
+
)
|
|
276
|
+
if resolved:
|
|
277
|
+
conflict_turns += 1
|
|
278
|
+
recovery = replace(recovery, force_new_turn=False)
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
await continue_rebase()
|
|
282
|
+
except RebaseConflict as error:
|
|
283
|
+
context = str(error)
|
|
284
|
+
resolved = False
|
|
285
|
+
recovery = replace(recovery, resumed_report=False, force_new_turn=True)
|
|
286
|
+
continue
|
|
287
|
+
current, verdict = await self._verify_conflicts(current)
|
|
288
|
+
if verdict.decision == DECISION_ACCEPT:
|
|
289
|
+
return current
|
|
290
|
+
context = "; ".join(issue.description for issue in verdict.issues)
|
|
291
|
+
conflict_turns = 0
|
|
292
|
+
resolved = False
|
|
293
|
+
recovery = ConflictRecovery()
|
|
294
|
+
|
|
295
|
+
async def _conflict_turn(
|
|
296
|
+
self, task: Task, context: str, force_new_turn: bool
|
|
297
|
+
) -> tuple[Task, str, bool]:
|
|
298
|
+
step = Step.CONSULT if task.step is Step.CONSULT else Step.INTEGRATE
|
|
299
|
+
current, _, stopped = await self._steps.run_step(
|
|
300
|
+
task,
|
|
301
|
+
step,
|
|
302
|
+
TurnCounters(),
|
|
303
|
+
conflict_context=context,
|
|
304
|
+
force_new_turn=step is Step.INTEGRATE and force_new_turn,
|
|
305
|
+
)
|
|
306
|
+
if stopped:
|
|
307
|
+
raise TurnStopped
|
|
308
|
+
latest = self._history.latest_report(current.id)
|
|
309
|
+
if step is Step.CONSULT:
|
|
310
|
+
if not isinstance(latest, ConsultAnswer):
|
|
311
|
+
message = f"{current.id} integration consult produced no answer"
|
|
312
|
+
raise InvariantError(message)
|
|
313
|
+
return current, latest.answer, False
|
|
314
|
+
if not isinstance(latest, ImplementationReport):
|
|
315
|
+
message = f"{current.id} conflicts turn produced no implementation report"
|
|
316
|
+
raise InvariantError(message)
|
|
317
|
+
if latest.status == STATUS_COMPLETED:
|
|
318
|
+
return current, context, True
|
|
319
|
+
if current.step is not Step.CONSULT:
|
|
320
|
+
message = f"{current.id} conflict report did not route to consult"
|
|
321
|
+
raise InvariantError(message)
|
|
322
|
+
return current, context, False
|
|
323
|
+
|
|
324
|
+
async def _verify_conflicts(self, task: Task) -> tuple[Task, Verdict]:
|
|
325
|
+
current, outcome = await self._steps.run_report_turn(
|
|
326
|
+
task,
|
|
327
|
+
role=Role.LEADER,
|
|
328
|
+
template=PromptName.VERIFY,
|
|
329
|
+
kind=ReportKind.VERDICT,
|
|
330
|
+
)
|
|
331
|
+
if not isinstance(outcome, Verdict):
|
|
332
|
+
message = f"{task.id} post-rebase turn produced no verdict"
|
|
333
|
+
raise InvariantError(message)
|
|
334
|
+
updated, escalated = await self._steps.advance(current, outcome, role=Role.LEADER)
|
|
335
|
+
if escalated:
|
|
336
|
+
raise TurnStopped
|
|
337
|
+
return updated, outcome
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Task window creation and periodic pane restoration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shikiru.config.settings import LayoutSplit, PaneLeaf
|
|
10
|
+
from shikiru.domain.commands import HandBack
|
|
11
|
+
from shikiru.domain.events import EventKind
|
|
12
|
+
from shikiru.domain.tasks import ACTIVE_STATUSES, Role, Task
|
|
13
|
+
from shikiru.errors import InvariantError
|
|
14
|
+
from shikiru.paths import task_root
|
|
15
|
+
from shikiru.private import make_private_dir
|
|
16
|
+
from shikiru.tmux.client import (
|
|
17
|
+
FORMAT_LAYOUT_PANE,
|
|
18
|
+
FORMAT_PANE_DEAD,
|
|
19
|
+
FORMAT_PANE_ID,
|
|
20
|
+
FORMAT_WINDOW_ID,
|
|
21
|
+
LAYOUT_PANE_OPTION,
|
|
22
|
+
PANE_DEAD_FLAG,
|
|
23
|
+
)
|
|
24
|
+
from shikiru.tmux.decorator import render_title
|
|
25
|
+
from shikiru.view_log import view_argv
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from shikiru.config.settings import Settings
|
|
32
|
+
from shikiru.engine.fault_reporter import FaultReporter
|
|
33
|
+
from shikiru.store.commands import CommandQueue
|
|
34
|
+
from shikiru.store.events import EventLog
|
|
35
|
+
from shikiru.store.runs import RunStore
|
|
36
|
+
from shikiru.store.tasks import TaskStore
|
|
37
|
+
from shikiru.tmux.client import Tmux
|
|
38
|
+
from shikiru.tmux.decorator import PaneDecorator
|
|
39
|
+
from shikiru.tmux.layout import LayoutBuilder
|
|
40
|
+
from shikiru.tmux.session import SessionView
|
|
41
|
+
|
|
42
|
+
_SHELL_ARGV = ("/bin/sh",)
|
|
43
|
+
_ROLE_PANE_NAMES = frozenset((Role.LEADER.value, Role.IMPLEMENTER.value))
|
|
44
|
+
_SHELL_PANE_NAME = "shell"
|
|
45
|
+
_LAYOUT_PANE_SEPARATOR = "\t"
|
|
46
|
+
_LAYOUT_PANE_FORMAT = f"{FORMAT_PANE_ID}{_LAYOUT_PANE_SEPARATOR}{FORMAT_LAYOUT_PANE}"
|
|
47
|
+
_SNAPSHOT_PANE_FORMAT = _LAYOUT_PANE_SEPARATOR.join(
|
|
48
|
+
(FORMAT_WINDOW_ID, FORMAT_PANE_ID, FORMAT_PANE_DEAD, FORMAT_LAYOUT_PANE)
|
|
49
|
+
)
|
|
50
|
+
_SNAPSHOT_FIELD_COUNT = 4
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class _PaneRow:
|
|
55
|
+
window_id: str
|
|
56
|
+
pane_id: str
|
|
57
|
+
dead: bool
|
|
58
|
+
layout_name: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_snapshot(lines: list[str]) -> list[_PaneRow]:
|
|
62
|
+
rows: list[_PaneRow] = []
|
|
63
|
+
for line in lines:
|
|
64
|
+
window_id, pane_id, dead, layout_name = line.split(
|
|
65
|
+
_LAYOUT_PANE_SEPARATOR, _SNAPSHOT_FIELD_COUNT - 1
|
|
66
|
+
)
|
|
67
|
+
rows.append(_PaneRow(window_id, pane_id, dead == PANE_DEAD_FLAG, layout_name))
|
|
68
|
+
return rows
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class LayoutGuard:
|
|
72
|
+
"""Keeps each task's tmux window and non-agent panes aligned with persisted state."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
tmux: Tmux,
|
|
78
|
+
view: SessionView,
|
|
79
|
+
builder: LayoutBuilder,
|
|
80
|
+
decorator: PaneDecorator,
|
|
81
|
+
session_target: str,
|
|
82
|
+
tasks: TaskStore,
|
|
83
|
+
settings: Settings,
|
|
84
|
+
project_root: Path,
|
|
85
|
+
events: EventLog,
|
|
86
|
+
runs: RunStore,
|
|
87
|
+
commands: CommandQueue,
|
|
88
|
+
faults: FaultReporter,
|
|
89
|
+
refresh_titles: Callable[[Task], Awaitable[None]],
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Create windows through `tmux`, storing pane ids through `tasks`.
|
|
92
|
+
|
|
93
|
+
A taken-over role's pane exiting is queued as a `HandBack` on `commands`. A task
|
|
94
|
+
whose restoration fails is recorded through `faults` without stopping the others. Role pane
|
|
95
|
+
titles are re-applied through `refresh_titles` whenever role panes are created or rebuilt.
|
|
96
|
+
"""
|
|
97
|
+
self._tmux = tmux
|
|
98
|
+
self._session_target = session_target
|
|
99
|
+
self._tasks = tasks
|
|
100
|
+
self._settings = settings
|
|
101
|
+
self._project_root = project_root
|
|
102
|
+
self._events = events
|
|
103
|
+
self._view = view
|
|
104
|
+
self._builder = builder
|
|
105
|
+
self._decorator = decorator
|
|
106
|
+
self._runs = runs
|
|
107
|
+
self._commands = commands
|
|
108
|
+
self._faults = faults
|
|
109
|
+
self._refresh_titles = refresh_titles
|
|
110
|
+
self._layout_pane_names = _layout_pane_names(settings.dashboard.layout)
|
|
111
|
+
self._special_pane_names = tuple(
|
|
112
|
+
name for name in self._layout_pane_names if name not in _ROLE_PANE_NAMES
|
|
113
|
+
)
|
|
114
|
+
self._special_panes: dict[str, dict[str, str]] = {}
|
|
115
|
+
self._windows = asyncio.Lock()
|
|
116
|
+
|
|
117
|
+
async def ensure(self, task: Task) -> Task:
|
|
118
|
+
"""Create `task`'s window or restore missing panes, returning fresh task state."""
|
|
119
|
+
if task.window_id is None or not await self._tmux.target_exists(task.window_id):
|
|
120
|
+
created = await self._create(task)
|
|
121
|
+
return await self._refresh_cache(created.id)
|
|
122
|
+
missing_agents = await self._missing_agent_roles(task)
|
|
123
|
+
missing_special = await self._missing_special_panes(task)
|
|
124
|
+
if missing_agents or missing_special:
|
|
125
|
+
await self._restore_missing_panes(task, missing_agents)
|
|
126
|
+
await self._restore_special_panes(task)
|
|
127
|
+
restored = await self._refresh_cache(task.id)
|
|
128
|
+
self._release_lost_takeovers(task.id, missing_agents)
|
|
129
|
+
await self._restore_view_panes(restored)
|
|
130
|
+
if missing_agents:
|
|
131
|
+
self._record_restored(task.id, "agent")
|
|
132
|
+
for pane in missing_special:
|
|
133
|
+
self._record_restored(task.id, pane)
|
|
134
|
+
return restored
|
|
135
|
+
|
|
136
|
+
async def restore(self, tasks: list[Task]) -> None:
|
|
137
|
+
"""Restore the windows and dead panes of `tasks` that are still active when reached.
|
|
138
|
+
|
|
139
|
+
A task that cannot be restored is recorded as an `engine_fault` and skipped.
|
|
140
|
+
"""
|
|
141
|
+
snapshot: list[_PaneRow] | None = None
|
|
142
|
+
for task in tasks:
|
|
143
|
+
async with self._faults.isolate("layout restore", task_id=task.id), self._windows:
|
|
144
|
+
current = self._tasks.get(task.id)
|
|
145
|
+
if current is None or current.status not in ACTIVE_STATUSES:
|
|
146
|
+
continue
|
|
147
|
+
if snapshot is None:
|
|
148
|
+
snapshot = _parse_snapshot(
|
|
149
|
+
await self._tmux.list_session_panes(
|
|
150
|
+
self._session_target, _SNAPSHOT_PANE_FORMAT
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
if self._is_healthy(current, snapshot):
|
|
154
|
+
continue
|
|
155
|
+
snapshot = None
|
|
156
|
+
await self.ensure(current)
|
|
157
|
+
|
|
158
|
+
def _is_healthy(self, task: Task, snapshot: list[_PaneRow]) -> bool:
|
|
159
|
+
if task.window_id is None:
|
|
160
|
+
return False
|
|
161
|
+
window_rows = [row for row in snapshot if row.window_id == task.window_id]
|
|
162
|
+
if not window_rows:
|
|
163
|
+
return False
|
|
164
|
+
session_panes = {row.pane_id for row in snapshot}
|
|
165
|
+
for role in (Role.LEADER, Role.IMPLEMENTER):
|
|
166
|
+
if task.panes.get(role) not in session_panes:
|
|
167
|
+
return False
|
|
168
|
+
window_names = {row.layout_name for row in window_rows}
|
|
169
|
+
if not all(name in window_names for name in self._special_pane_names):
|
|
170
|
+
return False
|
|
171
|
+
managed = set(task.panes.values())
|
|
172
|
+
managed.update(
|
|
173
|
+
row.pane_id for row in window_rows if row.layout_name in self._special_pane_names
|
|
174
|
+
)
|
|
175
|
+
return not any(row.dead and row.pane_id in managed for row in snapshot)
|
|
176
|
+
|
|
177
|
+
async def close(self, task: Task) -> None:
|
|
178
|
+
"""Close a task window when its task reaches a terminal state."""
|
|
179
|
+
async with self._windows:
|
|
180
|
+
if task.window_id is not None and await self._tmux.target_exists(task.window_id):
|
|
181
|
+
await self._view.close_task_window(task.window_id)
|
|
182
|
+
self._special_panes.pop(task.id, None)
|
|
183
|
+
|
|
184
|
+
async def _create(self, task: Task) -> Task:
|
|
185
|
+
root = task_root(self._project_root, task.id)
|
|
186
|
+
make_private_dir(root)
|
|
187
|
+
fields = {"task_id": task.id, "title": task.title}
|
|
188
|
+
window_name = render_title(self._settings.dashboard.titles.window, fields)
|
|
189
|
+
base = await self._view.create_task_window(
|
|
190
|
+
self._session_target,
|
|
191
|
+
window_name=window_name,
|
|
192
|
+
cwd=root,
|
|
193
|
+
)
|
|
194
|
+
window_id = await self._tmux.window_id(base)
|
|
195
|
+
panes = await self._builder.build(base, self._settings.dashboard.layout, cwd=root)
|
|
196
|
+
role_panes = {
|
|
197
|
+
Role(name): pane_id for name, pane_id in panes.items() if name in _ROLE_PANE_NAMES
|
|
198
|
+
}
|
|
199
|
+
self._special_panes[task.id] = {
|
|
200
|
+
name: pane_id for name, pane_id in panes.items() if name in self._special_pane_names
|
|
201
|
+
}
|
|
202
|
+
await self._decorate_special_panes(root, panes)
|
|
203
|
+
await self._decorator.configure_formats(window_id)
|
|
204
|
+
for role, pane_id in role_panes.items():
|
|
205
|
+
await self.start_view(task.id, role, pane_id)
|
|
206
|
+
self._tasks.set_window(task.id, window_id)
|
|
207
|
+
self._tasks.set_panes(task.id, role_panes)
|
|
208
|
+
self._release_lost_takeovers(task.id, set(role_panes))
|
|
209
|
+
updated = self._tasks.get(task.id)
|
|
210
|
+
if updated is None:
|
|
211
|
+
message = f"{task.id} vanished while recording its task window"
|
|
212
|
+
raise InvariantError(message)
|
|
213
|
+
await self._refresh_titles(updated)
|
|
214
|
+
return updated
|
|
215
|
+
|
|
216
|
+
async def _refresh_cache(self, task_id: str) -> Task:
|
|
217
|
+
refreshed = self._tasks.get(task_id)
|
|
218
|
+
if refreshed is None:
|
|
219
|
+
message = f"{task_id} vanished while refreshing its task window"
|
|
220
|
+
raise InvariantError(message)
|
|
221
|
+
return refreshed
|
|
222
|
+
|
|
223
|
+
async def _missing_agent_roles(self, task: Task) -> set[Role]:
|
|
224
|
+
missing: set[Role] = set()
|
|
225
|
+
for role in (Role.LEADER, Role.IMPLEMENTER):
|
|
226
|
+
pane_id = task.panes.get(role)
|
|
227
|
+
if pane_id is None or not await self._tmux.pane_exists(pane_id):
|
|
228
|
+
missing.add(role)
|
|
229
|
+
return missing
|
|
230
|
+
|
|
231
|
+
async def _missing_special_panes(self, task: Task) -> set[str]:
|
|
232
|
+
if task.window_id is None:
|
|
233
|
+
message = f"{task.id} has no window while checking special panes"
|
|
234
|
+
raise InvariantError(message)
|
|
235
|
+
special = self._special_panes.setdefault(task.id, {})
|
|
236
|
+
pane_rows = await self._tmux.list_panes(task.window_id, _LAYOUT_PANE_FORMAT)
|
|
237
|
+
window_panes = {row.split(_LAYOUT_PANE_SEPARATOR, 1)[0] for row in pane_rows}
|
|
238
|
+
for name, pane_id in tuple(special.items()):
|
|
239
|
+
if pane_id not in window_panes:
|
|
240
|
+
del special[name]
|
|
241
|
+
for row in pane_rows:
|
|
242
|
+
pane_id, name = row.split(_LAYOUT_PANE_SEPARATOR, 1)
|
|
243
|
+
if name in self._special_pane_names:
|
|
244
|
+
special[name] = pane_id
|
|
245
|
+
available = [
|
|
246
|
+
pane_id
|
|
247
|
+
for pane_id in window_panes
|
|
248
|
+
if pane_id not in task.panes.values() and pane_id not in special.values()
|
|
249
|
+
]
|
|
250
|
+
available.sort()
|
|
251
|
+
for name in self._special_pane_names:
|
|
252
|
+
if name not in special and available:
|
|
253
|
+
special[name] = available.pop(0)
|
|
254
|
+
await self._tmux.set_pane_option(special[name], LAYOUT_PANE_OPTION, name)
|
|
255
|
+
return {name for name in self._special_pane_names if name not in special}
|
|
256
|
+
|
|
257
|
+
async def _restore_missing_panes(self, task: Task, missing_agents: set[Role]) -> None:
|
|
258
|
+
if task.window_id is None:
|
|
259
|
+
message = f"{task.id} has no window while restoring panes"
|
|
260
|
+
raise InvariantError(message)
|
|
261
|
+
root = task_root(self._project_root, task.id)
|
|
262
|
+
existing = {
|
|
263
|
+
role.value: pane_id
|
|
264
|
+
for role, pane_id in task.panes.items()
|
|
265
|
+
if await self._tmux.pane_exists(pane_id)
|
|
266
|
+
}
|
|
267
|
+
special = self._special_panes.setdefault(task.id, {})
|
|
268
|
+
existing.update(
|
|
269
|
+
{
|
|
270
|
+
name: pane_id
|
|
271
|
+
for name, pane_id in special.items()
|
|
272
|
+
if await self._tmux.pane_exists(pane_id)
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
panes = await self._builder.restore_missing(
|
|
276
|
+
self._settings.dashboard.layout, existing, cwd=root
|
|
277
|
+
)
|
|
278
|
+
await self._builder.restore_vertical_stack(
|
|
279
|
+
task.window_id, self._settings.dashboard.layout, panes
|
|
280
|
+
)
|
|
281
|
+
role_panes = {Role(name): panes[name] for name in _ROLE_PANE_NAMES if name in panes}
|
|
282
|
+
special.clear()
|
|
283
|
+
special.update({name: panes[name] for name in self._special_pane_names})
|
|
284
|
+
await self._decorate_special_panes(root, panes)
|
|
285
|
+
for role in missing_agents:
|
|
286
|
+
await self.start_view(task.id, role, role_panes[role])
|
|
287
|
+
self._tasks.set_panes(task.id, role_panes)
|
|
288
|
+
await self._refresh_titles(await self._refresh_cache(task.id))
|
|
289
|
+
|
|
290
|
+
async def _restore_special_panes(self, task: Task) -> None:
|
|
291
|
+
special = self._special_panes.get(task.id, {})
|
|
292
|
+
root = task_root(self._project_root, task.id)
|
|
293
|
+
for name, pane_id in special.items():
|
|
294
|
+
if not await self._tmux.is_pane_dead(pane_id):
|
|
295
|
+
continue
|
|
296
|
+
await self._tmux.respawn_pane(pane_id, _SHELL_ARGV, env={}, cwd=root)
|
|
297
|
+
await self._title_special_pane(name, pane_id, root)
|
|
298
|
+
self._record_restored(task.id, name)
|
|
299
|
+
|
|
300
|
+
async def _restore_view_panes(self, task: Task) -> None:
|
|
301
|
+
for role, pane_id in task.panes.items():
|
|
302
|
+
if not await self._tmux.is_pane_dead(pane_id):
|
|
303
|
+
continue
|
|
304
|
+
if self._runs.get_taken_over(task.id, role) is not None:
|
|
305
|
+
self._queue_hand_back(HandBack(task_id=task.id, role=role))
|
|
306
|
+
continue
|
|
307
|
+
await self.start_view(task.id, role, pane_id)
|
|
308
|
+
self._record_restored(task.id, role.value)
|
|
309
|
+
|
|
310
|
+
def _release_lost_takeovers(self, task_id: str, lost_roles: set[Role]) -> None:
|
|
311
|
+
for role in sorted(lost_roles):
|
|
312
|
+
if self._runs.get_taken_over(task_id, role) is not None and self._queue_hand_back(
|
|
313
|
+
HandBack(task_id=task_id, role=role)
|
|
314
|
+
):
|
|
315
|
+
self._events.append(
|
|
316
|
+
EventKind.TAKEOVER_LOST,
|
|
317
|
+
task_id=task_id,
|
|
318
|
+
message=f"{task_id} {role.value} takeover lost its pane; handing back",
|
|
319
|
+
data={"role": role.value},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _queue_hand_back(self, command: HandBack) -> bool:
|
|
323
|
+
if any(
|
|
324
|
+
isinstance(queued.command, HandBack)
|
|
325
|
+
and (queued.command.task_id, queued.command.role) == (command.task_id, command.role)
|
|
326
|
+
for queued in self._commands.pending()
|
|
327
|
+
):
|
|
328
|
+
return False
|
|
329
|
+
self._commands.append(command)
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
async def _decorate_special_panes(self, root: Path, panes: dict[str, str]) -> None:
|
|
333
|
+
for name in self._special_pane_names:
|
|
334
|
+
pane_id = panes[name]
|
|
335
|
+
await self._tmux.set_pane_option(pane_id, LAYOUT_PANE_OPTION, name)
|
|
336
|
+
await self._title_special_pane(name, pane_id, root)
|
|
337
|
+
|
|
338
|
+
async def _title_special_pane(self, name: str, pane_id: str, root: Path) -> None:
|
|
339
|
+
if name == _SHELL_PANE_NAME:
|
|
340
|
+
await self._decorator.set_pane_title(
|
|
341
|
+
pane_id, self._settings.dashboard.titles.shell, {"cwd": str(root)}
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
async def start_view(self, task_id: str, role: Role, pane_id: str) -> None:
|
|
345
|
+
"""Run `role`'s streaming view of `task_id` in `pane_id`."""
|
|
346
|
+
argv = view_argv(self._project_root, task_id, role.value)
|
|
347
|
+
root = task_root(self._project_root, task_id)
|
|
348
|
+
await self._tmux.respawn_pane(pane_id, argv, env={}, cwd=root)
|
|
349
|
+
|
|
350
|
+
def _record_restored(self, task_id: str, pane: str) -> None:
|
|
351
|
+
self._events.append(
|
|
352
|
+
EventKind.PANE_RESTORED,
|
|
353
|
+
task_id=task_id,
|
|
354
|
+
message=f"{task_id} pane restored: {pane}",
|
|
355
|
+
data={"pane": pane},
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _layout_pane_names(node: LayoutSplit | PaneLeaf) -> tuple[str, ...]:
|
|
360
|
+
if isinstance(node, PaneLeaf):
|
|
361
|
+
return (node.pane,)
|
|
362
|
+
return tuple(name for child in node.panes for name in _layout_pane_names(child))
|