spec-kitty-cli 0.12.1__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.
- spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
- spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
- spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
- spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
- spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
- specify_cli/__init__.py +171 -0
- specify_cli/acceptance.py +627 -0
- specify_cli/agent_utils/README.md +157 -0
- specify_cli/agent_utils/__init__.py +9 -0
- specify_cli/agent_utils/status.py +356 -0
- specify_cli/cli/__init__.py +6 -0
- specify_cli/cli/commands/__init__.py +46 -0
- specify_cli/cli/commands/accept.py +189 -0
- specify_cli/cli/commands/agent/__init__.py +22 -0
- specify_cli/cli/commands/agent/config.py +382 -0
- specify_cli/cli/commands/agent/context.py +191 -0
- specify_cli/cli/commands/agent/feature.py +1057 -0
- specify_cli/cli/commands/agent/release.py +11 -0
- specify_cli/cli/commands/agent/tasks.py +1253 -0
- specify_cli/cli/commands/agent/workflow.py +801 -0
- specify_cli/cli/commands/context.py +246 -0
- specify_cli/cli/commands/dashboard.py +85 -0
- specify_cli/cli/commands/implement.py +973 -0
- specify_cli/cli/commands/init.py +827 -0
- specify_cli/cli/commands/init_help.py +62 -0
- specify_cli/cli/commands/merge.py +755 -0
- specify_cli/cli/commands/mission.py +240 -0
- specify_cli/cli/commands/ops.py +265 -0
- specify_cli/cli/commands/orchestrate.py +640 -0
- specify_cli/cli/commands/repair.py +175 -0
- specify_cli/cli/commands/research.py +165 -0
- specify_cli/cli/commands/sync.py +364 -0
- specify_cli/cli/commands/upgrade.py +249 -0
- specify_cli/cli/commands/validate_encoding.py +186 -0
- specify_cli/cli/commands/validate_tasks.py +186 -0
- specify_cli/cli/commands/verify.py +310 -0
- specify_cli/cli/helpers.py +123 -0
- specify_cli/cli/step_tracker.py +91 -0
- specify_cli/cli/ui.py +192 -0
- specify_cli/core/__init__.py +53 -0
- specify_cli/core/agent_context.py +311 -0
- specify_cli/core/config.py +96 -0
- specify_cli/core/context_validation.py +362 -0
- specify_cli/core/dependency_graph.py +351 -0
- specify_cli/core/git_ops.py +129 -0
- specify_cli/core/multi_parent_merge.py +323 -0
- specify_cli/core/paths.py +260 -0
- specify_cli/core/project_resolver.py +110 -0
- specify_cli/core/stale_detection.py +263 -0
- specify_cli/core/tool_checker.py +79 -0
- specify_cli/core/utils.py +43 -0
- specify_cli/core/vcs/__init__.py +114 -0
- specify_cli/core/vcs/detection.py +341 -0
- specify_cli/core/vcs/exceptions.py +85 -0
- specify_cli/core/vcs/git.py +1304 -0
- specify_cli/core/vcs/jujutsu.py +1208 -0
- specify_cli/core/vcs/protocol.py +285 -0
- specify_cli/core/vcs/types.py +249 -0
- specify_cli/core/version_checker.py +261 -0
- specify_cli/core/worktree.py +506 -0
- specify_cli/dashboard/__init__.py +28 -0
- specify_cli/dashboard/diagnostics.py +204 -0
- specify_cli/dashboard/handlers/__init__.py +17 -0
- specify_cli/dashboard/handlers/api.py +143 -0
- specify_cli/dashboard/handlers/base.py +65 -0
- specify_cli/dashboard/handlers/features.py +390 -0
- specify_cli/dashboard/handlers/router.py +81 -0
- specify_cli/dashboard/handlers/static.py +50 -0
- specify_cli/dashboard/lifecycle.py +541 -0
- specify_cli/dashboard/scanner.py +437 -0
- specify_cli/dashboard/server.py +123 -0
- specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
- specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
- specify_cli/dashboard/static/spec-kitty.png +0 -0
- specify_cli/dashboard/templates/__init__.py +36 -0
- specify_cli/dashboard/templates/index.html +258 -0
- specify_cli/doc_generators.py +621 -0
- specify_cli/doc_state.py +408 -0
- specify_cli/frontmatter.py +384 -0
- specify_cli/gap_analysis.py +915 -0
- specify_cli/gitignore_manager.py +300 -0
- specify_cli/guards.py +145 -0
- specify_cli/legacy_detector.py +83 -0
- specify_cli/manifest.py +286 -0
- specify_cli/merge/__init__.py +63 -0
- specify_cli/merge/executor.py +653 -0
- specify_cli/merge/forecast.py +215 -0
- specify_cli/merge/ordering.py +126 -0
- specify_cli/merge/preflight.py +230 -0
- specify_cli/merge/state.py +185 -0
- specify_cli/merge/status_resolver.py +354 -0
- specify_cli/mission.py +654 -0
- specify_cli/missions/documentation/command-templates/implement.md +309 -0
- specify_cli/missions/documentation/command-templates/plan.md +275 -0
- specify_cli/missions/documentation/command-templates/review.md +344 -0
- specify_cli/missions/documentation/command-templates/specify.md +206 -0
- specify_cli/missions/documentation/command-templates/tasks.md +189 -0
- specify_cli/missions/documentation/mission.yaml +113 -0
- specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
- specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
- specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
- specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
- specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
- specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
- specify_cli/missions/documentation/templates/plan-template.md +269 -0
- specify_cli/missions/documentation/templates/release-template.md +222 -0
- specify_cli/missions/documentation/templates/spec-template.md +172 -0
- specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
- specify_cli/missions/documentation/templates/tasks-template.md +159 -0
- specify_cli/missions/research/command-templates/merge.md +388 -0
- specify_cli/missions/research/command-templates/plan.md +125 -0
- specify_cli/missions/research/command-templates/review.md +144 -0
- specify_cli/missions/research/command-templates/tasks.md +225 -0
- specify_cli/missions/research/mission.yaml +115 -0
- specify_cli/missions/research/templates/data-model-template.md +33 -0
- specify_cli/missions/research/templates/plan-template.md +161 -0
- specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
- specify_cli/missions/research/templates/research/source-register.csv +18 -0
- specify_cli/missions/research/templates/research-template.md +35 -0
- specify_cli/missions/research/templates/spec-template.md +64 -0
- specify_cli/missions/research/templates/task-prompt-template.md +148 -0
- specify_cli/missions/research/templates/tasks-template.md +114 -0
- specify_cli/missions/software-dev/command-templates/accept.md +75 -0
- specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
- specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
- specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
- specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
- specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
- specify_cli/missions/software-dev/command-templates/implement.md +41 -0
- specify_cli/missions/software-dev/command-templates/merge.md +383 -0
- specify_cli/missions/software-dev/command-templates/plan.md +171 -0
- specify_cli/missions/software-dev/command-templates/review.md +32 -0
- specify_cli/missions/software-dev/command-templates/specify.md +321 -0
- specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
- specify_cli/missions/software-dev/mission.yaml +100 -0
- specify_cli/missions/software-dev/templates/plan-template.md +132 -0
- specify_cli/missions/software-dev/templates/spec-template.md +116 -0
- specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
- specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
- specify_cli/orchestrator/__init__.py +75 -0
- specify_cli/orchestrator/agent_config.py +224 -0
- specify_cli/orchestrator/agents/__init__.py +170 -0
- specify_cli/orchestrator/agents/augment.py +112 -0
- specify_cli/orchestrator/agents/base.py +243 -0
- specify_cli/orchestrator/agents/claude.py +112 -0
- specify_cli/orchestrator/agents/codex.py +106 -0
- specify_cli/orchestrator/agents/copilot.py +137 -0
- specify_cli/orchestrator/agents/cursor.py +139 -0
- specify_cli/orchestrator/agents/gemini.py +115 -0
- specify_cli/orchestrator/agents/kilocode.py +94 -0
- specify_cli/orchestrator/agents/opencode.py +132 -0
- specify_cli/orchestrator/agents/qwen.py +96 -0
- specify_cli/orchestrator/config.py +455 -0
- specify_cli/orchestrator/executor.py +642 -0
- specify_cli/orchestrator/integration.py +1230 -0
- specify_cli/orchestrator/monitor.py +898 -0
- specify_cli/orchestrator/scheduler.py +832 -0
- specify_cli/orchestrator/state.py +508 -0
- specify_cli/orchestrator/testing/__init__.py +122 -0
- specify_cli/orchestrator/testing/availability.py +346 -0
- specify_cli/orchestrator/testing/fixtures.py +684 -0
- specify_cli/orchestrator/testing/paths.py +218 -0
- specify_cli/plan_validation.py +107 -0
- specify_cli/scripts/debug-dashboard-scan.py +61 -0
- specify_cli/scripts/tasks/acceptance_support.py +695 -0
- specify_cli/scripts/tasks/task_helpers.py +506 -0
- specify_cli/scripts/tasks/tasks_cli.py +848 -0
- specify_cli/scripts/validate_encoding.py +180 -0
- specify_cli/task_metadata_validation.py +274 -0
- specify_cli/tasks_support.py +447 -0
- specify_cli/template/__init__.py +47 -0
- specify_cli/template/asset_generator.py +206 -0
- specify_cli/template/github_client.py +334 -0
- specify_cli/template/manager.py +193 -0
- specify_cli/template/renderer.py +99 -0
- specify_cli/templates/AGENTS.md +190 -0
- specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
- specify_cli/templates/agent-file-template.md +35 -0
- specify_cli/templates/checklist-template.md +42 -0
- specify_cli/templates/claudeignore-template +58 -0
- specify_cli/templates/command-templates/accept.md +141 -0
- specify_cli/templates/command-templates/analyze.md +253 -0
- specify_cli/templates/command-templates/checklist.md +352 -0
- specify_cli/templates/command-templates/clarify.md +224 -0
- specify_cli/templates/command-templates/constitution.md +432 -0
- specify_cli/templates/command-templates/dashboard.md +175 -0
- specify_cli/templates/command-templates/implement.md +190 -0
- specify_cli/templates/command-templates/merge.md +374 -0
- specify_cli/templates/command-templates/plan.md +171 -0
- specify_cli/templates/command-templates/research.md +88 -0
- specify_cli/templates/command-templates/review.md +510 -0
- specify_cli/templates/command-templates/specify.md +321 -0
- specify_cli/templates/command-templates/status.md +92 -0
- specify_cli/templates/command-templates/tasks.md +199 -0
- specify_cli/templates/git-hooks/pre-commit +22 -0
- specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
- specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
- specify_cli/templates/plan-template.md +108 -0
- specify_cli/templates/spec-template.md +118 -0
- specify_cli/templates/task-prompt-template.md +165 -0
- specify_cli/templates/tasks-template.md +161 -0
- specify_cli/templates/vscode-settings.json +13 -0
- specify_cli/text_sanitization.py +225 -0
- specify_cli/upgrade/__init__.py +18 -0
- specify_cli/upgrade/detector.py +239 -0
- specify_cli/upgrade/metadata.py +182 -0
- specify_cli/upgrade/migrations/__init__.py +65 -0
- specify_cli/upgrade/migrations/base.py +80 -0
- specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
- specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
- specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
- specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
- specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
- specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
- specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
- specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
- specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
- specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
- specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
- specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
- specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
- specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
- specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
- specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
- specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
- specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
- specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
- specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
- specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
- specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
- specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
- specify_cli/upgrade/registry.py +121 -0
- specify_cli/upgrade/runner.py +284 -0
- specify_cli/validators/__init__.py +14 -0
- specify_cli/validators/paths.py +154 -0
- specify_cli/validators/research.py +428 -0
- specify_cli/verify_enhanced.py +270 -0
- specify_cli/workspace_context.py +224 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
"""Monitor for tracking execution completion and handling failures.
|
|
2
|
+
|
|
3
|
+
This module handles:
|
|
4
|
+
- Exit code detection and classification (T032)
|
|
5
|
+
- JSON output parsing from agents (T033)
|
|
6
|
+
- Retry logic with configurable limits (T034)
|
|
7
|
+
- Fallback strategy execution (T035)
|
|
8
|
+
- Lane status updates via existing commands (T036)
|
|
9
|
+
- Human escalation when all agents fail (T037)
|
|
10
|
+
|
|
11
|
+
Implemented in WP07.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|
22
|
+
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
|
|
26
|
+
from specify_cli.orchestrator.agents.base import InvocationResult
|
|
27
|
+
from specify_cli.orchestrator.config import (
|
|
28
|
+
AgentConfig,
|
|
29
|
+
FallbackStrategy,
|
|
30
|
+
OrchestrationStatus,
|
|
31
|
+
OrchestratorConfig,
|
|
32
|
+
WPStatus,
|
|
33
|
+
)
|
|
34
|
+
from specify_cli.orchestrator.state import (
|
|
35
|
+
OrchestrationRun,
|
|
36
|
+
WPExecution,
|
|
37
|
+
save_state,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# =============================================================================
|
|
47
|
+
# Constants
|
|
48
|
+
# =============================================================================
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Timeout exit code (same as Unix `timeout` command)
|
|
52
|
+
TIMEOUT_EXIT_CODE = 124
|
|
53
|
+
|
|
54
|
+
# Delay between retries (seconds)
|
|
55
|
+
RETRY_DELAY_SECONDS = 5
|
|
56
|
+
|
|
57
|
+
# Maximum error message length to store
|
|
58
|
+
MAX_ERROR_LENGTH = 500
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# =============================================================================
|
|
62
|
+
# Failure Types (T032)
|
|
63
|
+
# =============================================================================
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FailureType(str, Enum):
|
|
67
|
+
"""Classification of execution failures."""
|
|
68
|
+
|
|
69
|
+
TIMEOUT = "timeout"
|
|
70
|
+
AUTH_ERROR = "auth_error"
|
|
71
|
+
RATE_LIMIT = "rate_limit"
|
|
72
|
+
GENERAL_ERROR = "general_error"
|
|
73
|
+
NETWORK_ERROR = "network_error"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# =============================================================================
|
|
77
|
+
# Exit Code Detection (T032)
|
|
78
|
+
# =============================================================================
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_success(result: InvocationResult) -> bool:
|
|
82
|
+
"""Determine if invocation was successful.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
result: The invocation result to check.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if the invocation succeeded (exit code 0 and success flag).
|
|
89
|
+
"""
|
|
90
|
+
return result.exit_code == 0 and result.success
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def classify_failure(result: InvocationResult, agent_id: str) -> FailureType:
|
|
94
|
+
"""Classify the type of failure for appropriate handling.
|
|
95
|
+
|
|
96
|
+
Uses exit codes and stderr content to determine failure type,
|
|
97
|
+
which influences retry and fallback behavior.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
result: The failed invocation result.
|
|
101
|
+
agent_id: The agent that produced the result.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
FailureType indicating the nature of the failure.
|
|
105
|
+
"""
|
|
106
|
+
stderr_lower = result.stderr.lower()
|
|
107
|
+
|
|
108
|
+
# Timeout detection (exit code 124 is Unix timeout standard)
|
|
109
|
+
if result.exit_code == TIMEOUT_EXIT_CODE:
|
|
110
|
+
return FailureType.TIMEOUT
|
|
111
|
+
|
|
112
|
+
# Gemini-specific error codes
|
|
113
|
+
if agent_id == "gemini":
|
|
114
|
+
if result.exit_code == 41: # Gemini auth error
|
|
115
|
+
return FailureType.AUTH_ERROR
|
|
116
|
+
if result.exit_code == 42: # Gemini rate limit
|
|
117
|
+
return FailureType.RATE_LIMIT
|
|
118
|
+
|
|
119
|
+
# Claude-specific error patterns
|
|
120
|
+
if agent_id == "claude-code":
|
|
121
|
+
if "api key" in stderr_lower or "unauthorized" in stderr_lower:
|
|
122
|
+
return FailureType.AUTH_ERROR
|
|
123
|
+
if "rate limit" in stderr_lower or "429" in stderr_lower:
|
|
124
|
+
return FailureType.RATE_LIMIT
|
|
125
|
+
|
|
126
|
+
# Codex-specific patterns
|
|
127
|
+
if agent_id == "codex":
|
|
128
|
+
if "openai api key" in stderr_lower:
|
|
129
|
+
return FailureType.AUTH_ERROR
|
|
130
|
+
if "rate_limit_exceeded" in stderr_lower:
|
|
131
|
+
return FailureType.RATE_LIMIT
|
|
132
|
+
|
|
133
|
+
# Generic pattern matching for stderr content
|
|
134
|
+
if "authentication" in stderr_lower:
|
|
135
|
+
return FailureType.AUTH_ERROR
|
|
136
|
+
if "api key" in stderr_lower or "api_key" in stderr_lower:
|
|
137
|
+
return FailureType.AUTH_ERROR
|
|
138
|
+
if "unauthorized" in stderr_lower or "401" in stderr_lower:
|
|
139
|
+
return FailureType.AUTH_ERROR
|
|
140
|
+
|
|
141
|
+
if "rate limit" in stderr_lower or "rate_limit" in stderr_lower:
|
|
142
|
+
return FailureType.RATE_LIMIT
|
|
143
|
+
if "too many requests" in stderr_lower or "429" in stderr_lower:
|
|
144
|
+
return FailureType.RATE_LIMIT
|
|
145
|
+
|
|
146
|
+
if "network" in stderr_lower or "connection" in stderr_lower:
|
|
147
|
+
return FailureType.NETWORK_ERROR
|
|
148
|
+
if "timeout" in stderr_lower or "timed out" in stderr_lower:
|
|
149
|
+
return FailureType.NETWORK_ERROR
|
|
150
|
+
|
|
151
|
+
return FailureType.GENERAL_ERROR
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def should_retry(failure_type: FailureType) -> bool:
|
|
155
|
+
"""Determine if a failure type should be retried.
|
|
156
|
+
|
|
157
|
+
Some failures like auth errors won't be fixed by retrying.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
failure_type: The classified failure type.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
True if retrying the same agent might succeed.
|
|
164
|
+
"""
|
|
165
|
+
# Don't retry auth errors - they need user intervention
|
|
166
|
+
if failure_type == FailureType.AUTH_ERROR:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
# All other failures might be transient
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# =============================================================================
|
|
174
|
+
# JSON Output Parsing (T033)
|
|
175
|
+
# =============================================================================
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse_json_output(stdout: str) -> dict | None:
|
|
179
|
+
"""Parse JSON output from agent, handling JSONL format.
|
|
180
|
+
|
|
181
|
+
Agents may output JSON in different formats:
|
|
182
|
+
- Single JSON object
|
|
183
|
+
- JSONL (one JSON per line, final line is result)
|
|
184
|
+
- Embedded JSON in other output
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
stdout: Raw stdout from the agent.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Parsed JSON dict or None if parsing fails.
|
|
191
|
+
"""
|
|
192
|
+
if not stdout.strip():
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
# Try parsing entire output as single JSON
|
|
196
|
+
try:
|
|
197
|
+
return json.loads(stdout)
|
|
198
|
+
except json.JSONDecodeError:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
# Try parsing last non-empty lines as JSON (JSONL format)
|
|
202
|
+
lines = stdout.strip().split("\n")
|
|
203
|
+
for line in reversed(lines):
|
|
204
|
+
line = line.strip()
|
|
205
|
+
if not line:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# Only attempt parsing lines that look like JSON
|
|
209
|
+
if line.startswith("{") or line.startswith("["):
|
|
210
|
+
try:
|
|
211
|
+
return json.loads(line)
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def extract_result_data(json_data: dict | None) -> dict[str, Any]:
|
|
219
|
+
"""Extract useful fields from parsed JSON.
|
|
220
|
+
|
|
221
|
+
Normalizes different agent output formats into a standard structure.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
json_data: Parsed JSON from agent output.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Dict with normalized fields (files_modified, commits_made, etc.)
|
|
228
|
+
"""
|
|
229
|
+
if not json_data:
|
|
230
|
+
return {}
|
|
231
|
+
|
|
232
|
+
result: dict[str, Any] = {}
|
|
233
|
+
|
|
234
|
+
# Extract files modified - different agents use different keys
|
|
235
|
+
for key in ["files", "files_modified", "modified_files", "changedFiles"]:
|
|
236
|
+
if key in json_data and isinstance(json_data[key], list):
|
|
237
|
+
result["files_modified"] = [str(f) for f in json_data[key]]
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
# Extract commits - different agents use different keys
|
|
241
|
+
for key in ["commits", "commits_made", "commitShas", "commit_hashes"]:
|
|
242
|
+
if key in json_data and isinstance(json_data[key], list):
|
|
243
|
+
result["commits_made"] = [str(c) for c in json_data[key]]
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
# Extract errors
|
|
247
|
+
if "errors" in json_data:
|
|
248
|
+
errors = json_data["errors"]
|
|
249
|
+
if isinstance(errors, list):
|
|
250
|
+
result["errors"] = [str(e) for e in errors]
|
|
251
|
+
elif errors:
|
|
252
|
+
result["errors"] = [str(errors)]
|
|
253
|
+
elif "error" in json_data and json_data["error"]:
|
|
254
|
+
result["errors"] = [str(json_data["error"])]
|
|
255
|
+
|
|
256
|
+
# Extract warnings
|
|
257
|
+
if "warnings" in json_data:
|
|
258
|
+
warnings = json_data["warnings"]
|
|
259
|
+
if isinstance(warnings, list):
|
|
260
|
+
result["warnings"] = [str(w) for w in warnings]
|
|
261
|
+
elif warnings:
|
|
262
|
+
result["warnings"] = [str(warnings)]
|
|
263
|
+
elif "warning" in json_data and json_data["warning"]:
|
|
264
|
+
result["warnings"] = [str(json_data["warning"])]
|
|
265
|
+
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def analyze_output(result: InvocationResult) -> dict[str, Any]:
|
|
270
|
+
"""Analyze agent output and extract structured data.
|
|
271
|
+
|
|
272
|
+
Combines exit code analysis with JSON parsing for comprehensive result.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
result: The invocation result to analyze.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Dict with analysis results including any extracted structured data.
|
|
279
|
+
"""
|
|
280
|
+
analysis: dict[str, Any] = {
|
|
281
|
+
"success": is_success(result),
|
|
282
|
+
"exit_code": result.exit_code,
|
|
283
|
+
"duration_seconds": result.duration_seconds,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# Try to parse JSON from stdout
|
|
287
|
+
json_data = parse_json_output(result.stdout)
|
|
288
|
+
if json_data:
|
|
289
|
+
analysis["json_data"] = json_data
|
|
290
|
+
analysis.update(extract_result_data(json_data))
|
|
291
|
+
|
|
292
|
+
# Use data already extracted by invoker if no JSON found
|
|
293
|
+
if "files_modified" not in analysis and result.files_modified:
|
|
294
|
+
analysis["files_modified"] = result.files_modified
|
|
295
|
+
if "commits_made" not in analysis and result.commits_made:
|
|
296
|
+
analysis["commits_made"] = result.commits_made
|
|
297
|
+
if "errors" not in analysis and result.errors:
|
|
298
|
+
analysis["errors"] = result.errors
|
|
299
|
+
if "warnings" not in analysis and result.warnings:
|
|
300
|
+
analysis["warnings"] = result.warnings
|
|
301
|
+
|
|
302
|
+
return analysis
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# =============================================================================
|
|
306
|
+
# Retry Logic (T034)
|
|
307
|
+
# =============================================================================
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def execute_with_retry(
|
|
311
|
+
executor_fn: Callable[[], Awaitable[InvocationResult]],
|
|
312
|
+
wp_execution: WPExecution,
|
|
313
|
+
config: OrchestratorConfig,
|
|
314
|
+
role: str,
|
|
315
|
+
agent_id: str,
|
|
316
|
+
) -> InvocationResult:
|
|
317
|
+
"""Execute with retry logic.
|
|
318
|
+
|
|
319
|
+
Retries failed invocations up to the configured limit, with a delay
|
|
320
|
+
between attempts. Updates the WP execution state with retry count.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
executor_fn: Async function to execute (returns InvocationResult).
|
|
324
|
+
wp_execution: WP execution state to update.
|
|
325
|
+
config: Orchestrator config for retry limits.
|
|
326
|
+
role: "implementation" or "review".
|
|
327
|
+
agent_id: The agent being used (for failure classification).
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Final InvocationResult (success or last failure).
|
|
331
|
+
"""
|
|
332
|
+
max_retries = config.max_retries
|
|
333
|
+
attempt = 0
|
|
334
|
+
|
|
335
|
+
# Get current retry count for this role
|
|
336
|
+
if role == "implementation":
|
|
337
|
+
retries = wp_execution.implementation_retries
|
|
338
|
+
else:
|
|
339
|
+
retries = wp_execution.review_retries
|
|
340
|
+
|
|
341
|
+
while attempt <= max_retries:
|
|
342
|
+
result = await executor_fn()
|
|
343
|
+
|
|
344
|
+
if is_success(result):
|
|
345
|
+
logger.info(
|
|
346
|
+
f"WP {wp_execution.wp_id} {role} succeeded on attempt {attempt + 1}"
|
|
347
|
+
)
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
# Classify the failure
|
|
351
|
+
failure_type = classify_failure(result, agent_id)
|
|
352
|
+
logger.warning(
|
|
353
|
+
f"WP {wp_execution.wp_id} {role} failed: {failure_type.value}"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Store the error (truncated)
|
|
357
|
+
error_msg = result.stderr[:MAX_ERROR_LENGTH] if result.stderr else ""
|
|
358
|
+
if not error_msg and result.errors:
|
|
359
|
+
error_msg = "; ".join(result.errors)[:MAX_ERROR_LENGTH]
|
|
360
|
+
wp_execution.last_error = error_msg
|
|
361
|
+
|
|
362
|
+
# Check if this failure type should be retried
|
|
363
|
+
if not should_retry(failure_type):
|
|
364
|
+
logger.warning(
|
|
365
|
+
f"WP {wp_execution.wp_id} {role} failure type {failure_type.value} "
|
|
366
|
+
"is not retryable"
|
|
367
|
+
)
|
|
368
|
+
break
|
|
369
|
+
|
|
370
|
+
attempt += 1
|
|
371
|
+
retries += 1
|
|
372
|
+
|
|
373
|
+
# Update retry count in state
|
|
374
|
+
if role == "implementation":
|
|
375
|
+
wp_execution.implementation_retries = retries
|
|
376
|
+
else:
|
|
377
|
+
wp_execution.review_retries = retries
|
|
378
|
+
|
|
379
|
+
if attempt <= max_retries:
|
|
380
|
+
logger.info(
|
|
381
|
+
f"WP {wp_execution.wp_id} {role} retrying "
|
|
382
|
+
f"(attempt {attempt + 1}/{max_retries + 1})..."
|
|
383
|
+
)
|
|
384
|
+
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
|
385
|
+
|
|
386
|
+
return result
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# =============================================================================
|
|
390
|
+
# Fallback Strategy Execution (T035)
|
|
391
|
+
# =============================================================================
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def apply_fallback(
|
|
395
|
+
wp_id: str,
|
|
396
|
+
role: str,
|
|
397
|
+
failed_agent: str,
|
|
398
|
+
config: OrchestratorConfig,
|
|
399
|
+
state: OrchestrationRun,
|
|
400
|
+
) -> str | None:
|
|
401
|
+
"""Apply fallback strategy and return next agent to try.
|
|
402
|
+
|
|
403
|
+
Implements the configured fallback strategy to select the next
|
|
404
|
+
agent after a failure. Updates the WP execution state with
|
|
405
|
+
tried agents.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
wp_id: Work package ID.
|
|
409
|
+
role: "implementation" or "review".
|
|
410
|
+
failed_agent: The agent that just failed.
|
|
411
|
+
config: Orchestrator config with fallback strategy.
|
|
412
|
+
state: Orchestration run state.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Next agent ID to try, or None if no fallback available.
|
|
416
|
+
"""
|
|
417
|
+
strategy = config.fallback_strategy
|
|
418
|
+
wp_execution = state.work_packages[wp_id]
|
|
419
|
+
|
|
420
|
+
logger.info(
|
|
421
|
+
f"Applying fallback strategy '{strategy.value}' for {wp_id} {role}"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
if strategy == FallbackStrategy.FAIL:
|
|
425
|
+
# No fallback - fail immediately
|
|
426
|
+
logger.info("Fallback strategy is FAIL, no fallback attempt")
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
if strategy == FallbackStrategy.SAME_AGENT:
|
|
430
|
+
# In single-agent mode or same_agent strategy, just fail after retries
|
|
431
|
+
logger.info("Fallback strategy is SAME_AGENT, no fallback to other agents")
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
if strategy == FallbackStrategy.NEXT_IN_LIST:
|
|
435
|
+
# Track the failed agent
|
|
436
|
+
if failed_agent not in wp_execution.fallback_agents_tried:
|
|
437
|
+
wp_execution.fallback_agents_tried.append(failed_agent)
|
|
438
|
+
|
|
439
|
+
# Get candidates from defaults list for this role
|
|
440
|
+
candidates = config.defaults.get(role, [])
|
|
441
|
+
|
|
442
|
+
for agent_id in candidates:
|
|
443
|
+
# Skip agents we've already tried
|
|
444
|
+
if agent_id in wp_execution.fallback_agents_tried:
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
# Check if agent is enabled
|
|
448
|
+
agent_config = config.agents.get(agent_id)
|
|
449
|
+
if agent_config is None:
|
|
450
|
+
continue
|
|
451
|
+
if not agent_config.enabled:
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
# Check if agent supports this role
|
|
455
|
+
if role not in agent_config.roles:
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
logger.info(f"Fallback: next agent for {wp_id} {role} is {agent_id}")
|
|
459
|
+
return agent_id
|
|
460
|
+
|
|
461
|
+
# All candidates exhausted
|
|
462
|
+
logger.warning(
|
|
463
|
+
f"Fallback: all agents exhausted for {wp_id} {role}. "
|
|
464
|
+
f"Tried: {wp_execution.fallback_agents_tried}"
|
|
465
|
+
)
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
# Unknown strategy (shouldn't happen)
|
|
469
|
+
logger.error(f"Unknown fallback strategy: {strategy}")
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def get_available_fallback_agents(
|
|
474
|
+
wp_id: str,
|
|
475
|
+
role: str,
|
|
476
|
+
config: OrchestratorConfig,
|
|
477
|
+
state: OrchestrationRun,
|
|
478
|
+
) -> list[str]:
|
|
479
|
+
"""Get list of agents that haven't been tried yet for fallback.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
wp_id: Work package ID.
|
|
483
|
+
role: "implementation" or "review".
|
|
484
|
+
config: Orchestrator config.
|
|
485
|
+
state: Orchestration run state.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
List of agent IDs that are available for fallback.
|
|
489
|
+
"""
|
|
490
|
+
wp_execution = state.work_packages[wp_id]
|
|
491
|
+
tried = set(wp_execution.fallback_agents_tried)
|
|
492
|
+
candidates = config.defaults.get(role, [])
|
|
493
|
+
|
|
494
|
+
available = []
|
|
495
|
+
for agent_id in candidates:
|
|
496
|
+
if agent_id in tried:
|
|
497
|
+
continue
|
|
498
|
+
agent_config = config.agents.get(agent_id)
|
|
499
|
+
if agent_config and agent_config.enabled and role in agent_config.roles:
|
|
500
|
+
available.append(agent_id)
|
|
501
|
+
|
|
502
|
+
return available
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# =============================================================================
|
|
506
|
+
# Lane Status Updates (T036)
|
|
507
|
+
# =============================================================================
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
async def update_wp_lane(
|
|
511
|
+
wp_id: str,
|
|
512
|
+
lane: str,
|
|
513
|
+
note: str,
|
|
514
|
+
repo_root: Path,
|
|
515
|
+
) -> bool:
|
|
516
|
+
"""Update WP lane status using spec-kitty command.
|
|
517
|
+
|
|
518
|
+
Calls the existing CLI command to update the lane, ensuring
|
|
519
|
+
proper integration with the rest of the spec-kitty workflow.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
wp_id: Work package ID.
|
|
523
|
+
lane: Target lane (doing, for_review, done).
|
|
524
|
+
note: Status note for the history.
|
|
525
|
+
repo_root: Repository root for command execution.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
True if the command succeeded.
|
|
529
|
+
"""
|
|
530
|
+
cmd = [
|
|
531
|
+
"spec-kitty",
|
|
532
|
+
"agent",
|
|
533
|
+
"tasks",
|
|
534
|
+
"move-task",
|
|
535
|
+
wp_id,
|
|
536
|
+
"--to",
|
|
537
|
+
lane,
|
|
538
|
+
"--note",
|
|
539
|
+
note,
|
|
540
|
+
]
|
|
541
|
+
|
|
542
|
+
logger.info(f"Updating {wp_id} lane to '{lane}'")
|
|
543
|
+
logger.debug(f"Command: {' '.join(cmd)}")
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
process = await asyncio.create_subprocess_exec(
|
|
547
|
+
*cmd,
|
|
548
|
+
cwd=repo_root,
|
|
549
|
+
stdout=asyncio.subprocess.PIPE,
|
|
550
|
+
stderr=asyncio.subprocess.PIPE,
|
|
551
|
+
)
|
|
552
|
+
stdout, stderr = await process.communicate()
|
|
553
|
+
|
|
554
|
+
if process.returncode == 0:
|
|
555
|
+
logger.info(f"Successfully updated {wp_id} to '{lane}'")
|
|
556
|
+
return True
|
|
557
|
+
else:
|
|
558
|
+
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
559
|
+
logger.error(f"Failed to update {wp_id} lane: {stderr_text}")
|
|
560
|
+
return False
|
|
561
|
+
|
|
562
|
+
except FileNotFoundError:
|
|
563
|
+
logger.error("spec-kitty command not found. Is spec-kitty-cli installed?")
|
|
564
|
+
return False
|
|
565
|
+
except Exception as e:
|
|
566
|
+
logger.error(f"Unexpected error updating lane: {e}")
|
|
567
|
+
return False
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
async def mark_subtask_done(
|
|
571
|
+
subtask_id: str,
|
|
572
|
+
repo_root: Path,
|
|
573
|
+
) -> bool:
|
|
574
|
+
"""Mark a subtask as done using spec-kitty command.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
subtask_id: Subtask ID (e.g., "T001").
|
|
578
|
+
repo_root: Repository root for command execution.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
True if the command succeeded.
|
|
582
|
+
"""
|
|
583
|
+
cmd = [
|
|
584
|
+
"spec-kitty",
|
|
585
|
+
"agent",
|
|
586
|
+
"tasks",
|
|
587
|
+
"mark-status",
|
|
588
|
+
subtask_id,
|
|
589
|
+
"--status",
|
|
590
|
+
"done",
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
logger.info(f"Marking subtask {subtask_id} as done")
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
process = await asyncio.create_subprocess_exec(
|
|
597
|
+
*cmd,
|
|
598
|
+
cwd=repo_root,
|
|
599
|
+
stdout=asyncio.subprocess.PIPE,
|
|
600
|
+
stderr=asyncio.subprocess.PIPE,
|
|
601
|
+
)
|
|
602
|
+
await process.communicate()
|
|
603
|
+
return process.returncode == 0
|
|
604
|
+
except Exception as e:
|
|
605
|
+
logger.error(f"Failed to mark subtask done: {e}")
|
|
606
|
+
return False
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# Lane transition helpers
|
|
610
|
+
LANE_TRANSITIONS = {
|
|
611
|
+
# (from_status, event) -> (to_status, to_lane)
|
|
612
|
+
# Starting implementation
|
|
613
|
+
(WPStatus.PENDING, "start_implementation"): (WPStatus.IMPLEMENTATION, "doing"),
|
|
614
|
+
(WPStatus.READY, "start_implementation"): (WPStatus.IMPLEMENTATION, "doing"),
|
|
615
|
+
# Idempotent: already implementing, stay in implementation
|
|
616
|
+
(WPStatus.IMPLEMENTATION, "start_implementation"): (WPStatus.IMPLEMENTATION, "doing"),
|
|
617
|
+
# Completing implementation
|
|
618
|
+
(WPStatus.IMPLEMENTATION, "complete_implementation"): (
|
|
619
|
+
WPStatus.REVIEW,
|
|
620
|
+
"for_review",
|
|
621
|
+
),
|
|
622
|
+
# Idempotent: already in review, stay in review
|
|
623
|
+
(WPStatus.REVIEW, "complete_implementation"): (WPStatus.REVIEW, "for_review"),
|
|
624
|
+
# Completing review
|
|
625
|
+
(WPStatus.REVIEW, "complete_review"): (WPStatus.COMPLETED, "done"),
|
|
626
|
+
# Rework: going back to implementation
|
|
627
|
+
(WPStatus.REWORK, "start_implementation"): (WPStatus.IMPLEMENTATION, "doing"),
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
async def transition_wp_lane(
|
|
632
|
+
wp_execution: WPExecution,
|
|
633
|
+
event: str,
|
|
634
|
+
repo_root: Path,
|
|
635
|
+
) -> bool:
|
|
636
|
+
"""Transition WP to the next lane based on event.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
wp_execution: The WP execution state.
|
|
640
|
+
event: The event triggering the transition.
|
|
641
|
+
repo_root: Repository root for command execution.
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
True if transition succeeded.
|
|
645
|
+
"""
|
|
646
|
+
current_status = wp_execution.status
|
|
647
|
+
key = (current_status, event)
|
|
648
|
+
|
|
649
|
+
if key not in LANE_TRANSITIONS:
|
|
650
|
+
logger.warning(
|
|
651
|
+
f"No transition defined for {wp_execution.wp_id} "
|
|
652
|
+
f"from {current_status.value} on event '{event}'"
|
|
653
|
+
)
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
new_status, new_lane = LANE_TRANSITIONS[key]
|
|
657
|
+
note = f"Automated: {event.replace('_', ' ')}"
|
|
658
|
+
|
|
659
|
+
success = await update_wp_lane(
|
|
660
|
+
wp_execution.wp_id,
|
|
661
|
+
new_lane,
|
|
662
|
+
note,
|
|
663
|
+
repo_root,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
if success:
|
|
667
|
+
wp_execution.status = new_status
|
|
668
|
+
logger.info(
|
|
669
|
+
f"{wp_execution.wp_id}: {current_status.value} -> {new_status.value}"
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
return success
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# =============================================================================
|
|
676
|
+
# Human Escalation (T037)
|
|
677
|
+
# =============================================================================
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
async def escalate_to_human(
|
|
681
|
+
wp_id: str,
|
|
682
|
+
role: str,
|
|
683
|
+
state: OrchestrationRun,
|
|
684
|
+
repo_root: Path,
|
|
685
|
+
console: Console | None = None,
|
|
686
|
+
) -> None:
|
|
687
|
+
"""Pause orchestration and alert user when all agents fail.
|
|
688
|
+
|
|
689
|
+
Sets orchestration status to PAUSED and prints clear instructions
|
|
690
|
+
for the user on how to proceed.
|
|
691
|
+
|
|
692
|
+
Args:
|
|
693
|
+
wp_id: The WP that failed.
|
|
694
|
+
role: The role that failed ("implementation" or "review").
|
|
695
|
+
state: Orchestration run state to update.
|
|
696
|
+
repo_root: Repository root for state persistence.
|
|
697
|
+
console: Rich console for output (creates one if not provided).
|
|
698
|
+
"""
|
|
699
|
+
if console is None:
|
|
700
|
+
console = Console()
|
|
701
|
+
|
|
702
|
+
wp_execution = state.work_packages[wp_id]
|
|
703
|
+
|
|
704
|
+
# Update state
|
|
705
|
+
state.status = OrchestrationStatus.PAUSED
|
|
706
|
+
wp_execution.status = WPStatus.FAILED
|
|
707
|
+
state.wps_failed += 1
|
|
708
|
+
|
|
709
|
+
# Save state for resume capability
|
|
710
|
+
save_state(state, repo_root)
|
|
711
|
+
|
|
712
|
+
# Get log file path if available
|
|
713
|
+
log_file_info = ""
|
|
714
|
+
if wp_execution.log_file:
|
|
715
|
+
log_file_info = f"\nLog file: {wp_execution.log_file}"
|
|
716
|
+
|
|
717
|
+
# Get tried agents info
|
|
718
|
+
tried_agents = wp_execution.fallback_agents_tried
|
|
719
|
+
tried_info = f"Agents tried: {', '.join(tried_agents)}" if tried_agents else ""
|
|
720
|
+
|
|
721
|
+
# Format the error message
|
|
722
|
+
last_error = wp_execution.last_error or "No error message captured"
|
|
723
|
+
if len(last_error) > 300:
|
|
724
|
+
last_error = last_error[:300] + "..."
|
|
725
|
+
|
|
726
|
+
# Print alert panel
|
|
727
|
+
console.print()
|
|
728
|
+
console.print(
|
|
729
|
+
Panel(
|
|
730
|
+
f"[bold red]Orchestration Paused[/bold red]\n\n"
|
|
731
|
+
f"Work package [bold]{wp_id}[/bold] failed during {role}.\n"
|
|
732
|
+
f"All agents exhausted after retries and fallbacks.\n"
|
|
733
|
+
f"{tried_info}\n\n"
|
|
734
|
+
f"[bold]Last error:[/bold]\n{last_error}\n"
|
|
735
|
+
f"{log_file_info}\n\n"
|
|
736
|
+
f"[bold]Options:[/bold]\n"
|
|
737
|
+
f"1. Fix the issue and resume:\n"
|
|
738
|
+
f" [cyan]spec-kitty orchestrate --resume[/cyan]\n\n"
|
|
739
|
+
f"2. Skip this WP and continue:\n"
|
|
740
|
+
f" [cyan]spec-kitty orchestrate --skip {wp_id}[/cyan]\n\n"
|
|
741
|
+
f"3. Abort the orchestration:\n"
|
|
742
|
+
f" [cyan]spec-kitty orchestrate --abort[/cyan]",
|
|
743
|
+
title="Human Intervention Required",
|
|
744
|
+
border_style="red",
|
|
745
|
+
)
|
|
746
|
+
)
|
|
747
|
+
console.print()
|
|
748
|
+
|
|
749
|
+
logger.info(f"Orchestration paused: {wp_id} failed during {role}")
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def get_escalation_summary(state: OrchestrationRun) -> dict[str, Any]:
|
|
753
|
+
"""Get a summary of escalation state for programmatic access.
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
state: Orchestration run state.
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
Dict with escalation details.
|
|
760
|
+
"""
|
|
761
|
+
failed_wps = [
|
|
762
|
+
wp_id
|
|
763
|
+
for wp_id, wp in state.work_packages.items()
|
|
764
|
+
if wp.status == WPStatus.FAILED
|
|
765
|
+
]
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
"is_paused": state.status == OrchestrationStatus.PAUSED,
|
|
769
|
+
"failed_wps": failed_wps,
|
|
770
|
+
"total_wps": state.wps_total,
|
|
771
|
+
"completed_wps": state.wps_completed,
|
|
772
|
+
"failed_count": state.wps_failed,
|
|
773
|
+
"details": {
|
|
774
|
+
wp_id: {
|
|
775
|
+
"last_error": state.work_packages[wp_id].last_error,
|
|
776
|
+
"agents_tried": state.work_packages[wp_id].fallback_agents_tried,
|
|
777
|
+
"log_file": str(state.work_packages[wp_id].log_file)
|
|
778
|
+
if state.work_packages[wp_id].log_file
|
|
779
|
+
else None,
|
|
780
|
+
}
|
|
781
|
+
for wp_id in failed_wps
|
|
782
|
+
},
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# =============================================================================
|
|
787
|
+
# Combined Monitor Functions
|
|
788
|
+
# =============================================================================
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
async def handle_wp_failure(
|
|
792
|
+
wp_id: str,
|
|
793
|
+
role: str,
|
|
794
|
+
failed_agent: str,
|
|
795
|
+
result: InvocationResult,
|
|
796
|
+
config: OrchestratorConfig,
|
|
797
|
+
state: OrchestrationRun,
|
|
798
|
+
repo_root: Path,
|
|
799
|
+
execute_phase_fn: Callable[
|
|
800
|
+
[str, str, str], Awaitable[InvocationResult]
|
|
801
|
+
] | None = None,
|
|
802
|
+
console: Console | None = None,
|
|
803
|
+
) -> InvocationResult | None:
|
|
804
|
+
"""Handle a WP phase failure with fallback and escalation.
|
|
805
|
+
|
|
806
|
+
Coordinates the fallback strategy and escalation flow after
|
|
807
|
+
a WP phase fails.
|
|
808
|
+
|
|
809
|
+
Args:
|
|
810
|
+
wp_id: Work package ID.
|
|
811
|
+
role: "implementation" or "review".
|
|
812
|
+
failed_agent: The agent that failed.
|
|
813
|
+
result: The failed invocation result.
|
|
814
|
+
config: Orchestrator config.
|
|
815
|
+
state: Orchestration run state.
|
|
816
|
+
repo_root: Repository root.
|
|
817
|
+
execute_phase_fn: Optional function to execute with a new agent.
|
|
818
|
+
Signature: (wp_id, role, agent_id) -> InvocationResult
|
|
819
|
+
console: Rich console for output.
|
|
820
|
+
|
|
821
|
+
Returns:
|
|
822
|
+
InvocationResult from successful retry/fallback, or None if
|
|
823
|
+
escalated to human.
|
|
824
|
+
"""
|
|
825
|
+
failure_type = classify_failure(result, failed_agent)
|
|
826
|
+
logger.info(f"Handling failure for {wp_id} {role}: {failure_type.value}")
|
|
827
|
+
|
|
828
|
+
# Try fallback if available
|
|
829
|
+
next_agent = apply_fallback(wp_id, role, failed_agent, config, state)
|
|
830
|
+
|
|
831
|
+
if next_agent and execute_phase_fn:
|
|
832
|
+
logger.info(f"Attempting fallback with {next_agent} for {wp_id} {role}")
|
|
833
|
+
return await execute_phase_fn(wp_id, role, next_agent)
|
|
834
|
+
|
|
835
|
+
# No fallback available - escalate to human
|
|
836
|
+
await escalate_to_human(wp_id, role, state, repo_root, console)
|
|
837
|
+
return None
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def update_wp_progress(
|
|
841
|
+
wp_execution: WPExecution,
|
|
842
|
+
result: InvocationResult,
|
|
843
|
+
role: str,
|
|
844
|
+
) -> None:
|
|
845
|
+
"""Update WP execution state based on result.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
wp_execution: WP execution state to update.
|
|
849
|
+
result: The invocation result.
|
|
850
|
+
role: "implementation" or "review".
|
|
851
|
+
"""
|
|
852
|
+
if role == "implementation":
|
|
853
|
+
wp_execution.implementation_exit_code = result.exit_code
|
|
854
|
+
else:
|
|
855
|
+
wp_execution.review_exit_code = result.exit_code
|
|
856
|
+
|
|
857
|
+
# Analyze output and update any extracted data
|
|
858
|
+
analysis = analyze_output(result)
|
|
859
|
+
|
|
860
|
+
if not is_success(result):
|
|
861
|
+
error_msg = result.stderr[:MAX_ERROR_LENGTH] if result.stderr else ""
|
|
862
|
+
if not error_msg and analysis.get("errors"):
|
|
863
|
+
error_msg = "; ".join(analysis["errors"])[:MAX_ERROR_LENGTH]
|
|
864
|
+
wp_execution.last_error = error_msg
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
__all__ = [
|
|
868
|
+
# Constants
|
|
869
|
+
"TIMEOUT_EXIT_CODE",
|
|
870
|
+
"RETRY_DELAY_SECONDS",
|
|
871
|
+
"MAX_ERROR_LENGTH",
|
|
872
|
+
# Enums
|
|
873
|
+
"FailureType",
|
|
874
|
+
# Exit code detection (T032)
|
|
875
|
+
"is_success",
|
|
876
|
+
"classify_failure",
|
|
877
|
+
"should_retry",
|
|
878
|
+
# JSON parsing (T033)
|
|
879
|
+
"parse_json_output",
|
|
880
|
+
"extract_result_data",
|
|
881
|
+
"analyze_output",
|
|
882
|
+
# Retry logic (T034)
|
|
883
|
+
"execute_with_retry",
|
|
884
|
+
# Fallback strategy (T035)
|
|
885
|
+
"apply_fallback",
|
|
886
|
+
"get_available_fallback_agents",
|
|
887
|
+
# Lane updates (T036)
|
|
888
|
+
"update_wp_lane",
|
|
889
|
+
"mark_subtask_done",
|
|
890
|
+
"transition_wp_lane",
|
|
891
|
+
"LANE_TRANSITIONS",
|
|
892
|
+
# Human escalation (T037)
|
|
893
|
+
"escalate_to_human",
|
|
894
|
+
"get_escalation_summary",
|
|
895
|
+
# Combined functions
|
|
896
|
+
"handle_wp_failure",
|
|
897
|
+
"update_wp_progress",
|
|
898
|
+
]
|