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,642 @@
|
|
|
1
|
+
"""Executor for spawning and managing agent processes.
|
|
2
|
+
|
|
3
|
+
This module handles:
|
|
4
|
+
- Async process spawning with asyncio.create_subprocess_exec
|
|
5
|
+
- Stdin piping for prompts
|
|
6
|
+
- stdout/stderr capture to log files
|
|
7
|
+
- Timeout enforcement with proper cleanup
|
|
8
|
+
- Worktree creation integration
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from asyncio.subprocess import Process
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from specify_cli.orchestrator.agents.base import AgentInvoker, BaseInvoker, InvocationResult
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Constants
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Exit code returned when execution times out
|
|
36
|
+
TIMEOUT_EXIT_CODE = 124 # Same as Unix `timeout` command
|
|
37
|
+
|
|
38
|
+
# Grace period for process to terminate before force kill
|
|
39
|
+
TERMINATION_GRACE_SECONDS = 5.0
|
|
40
|
+
|
|
41
|
+
# Default logs directory under .kittify
|
|
42
|
+
LOGS_DIRNAME = "logs"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# Exceptions
|
|
47
|
+
# =============================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ExecutorError(Exception):
|
|
51
|
+
"""Base exception for executor errors."""
|
|
52
|
+
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WorktreeCreationError(ExecutorError):
|
|
57
|
+
"""Raised when worktree creation fails."""
|
|
58
|
+
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProcessSpawnError(ExecutorError):
|
|
63
|
+
"""Raised when process spawning fails."""
|
|
64
|
+
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TimeoutError(ExecutorError):
|
|
69
|
+
"""Raised when execution times out."""
|
|
70
|
+
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# =============================================================================
|
|
75
|
+
# Async Process Spawning (T027)
|
|
76
|
+
# =============================================================================
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def spawn_agent(
|
|
80
|
+
invoker: AgentInvoker | BaseInvoker,
|
|
81
|
+
prompt: str,
|
|
82
|
+
working_dir: Path,
|
|
83
|
+
role: str,
|
|
84
|
+
) -> tuple[Process, list[str]]:
|
|
85
|
+
"""Spawn agent process.
|
|
86
|
+
|
|
87
|
+
Creates an asyncio subprocess for the agent with stdin, stdout, and
|
|
88
|
+
stderr pipes configured for capture.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
invoker: Agent invoker that knows how to build commands
|
|
92
|
+
prompt: The task prompt to send to the agent
|
|
93
|
+
working_dir: Directory where agent should execute
|
|
94
|
+
role: Either "implementation" or "review"
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Tuple of (process, command) for tracking
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ProcessSpawnError: If process creation fails
|
|
101
|
+
"""
|
|
102
|
+
# Build command using invoker
|
|
103
|
+
cmd = invoker.build_command(prompt, working_dir, role)
|
|
104
|
+
logger.info(f"Spawning {invoker.agent_id}: {' '.join(cmd[:3])}...")
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
process = await asyncio.create_subprocess_exec(
|
|
108
|
+
*cmd,
|
|
109
|
+
stdin=asyncio.subprocess.PIPE,
|
|
110
|
+
stdout=asyncio.subprocess.PIPE,
|
|
111
|
+
stderr=asyncio.subprocess.PIPE,
|
|
112
|
+
cwd=working_dir,
|
|
113
|
+
)
|
|
114
|
+
logger.debug(f"Process {process.pid} spawned for {invoker.agent_id}")
|
|
115
|
+
return process, cmd
|
|
116
|
+
|
|
117
|
+
except OSError as e:
|
|
118
|
+
raise ProcessSpawnError(
|
|
119
|
+
f"Failed to spawn {invoker.agent_id}: {e}"
|
|
120
|
+
) from e
|
|
121
|
+
except Exception as e:
|
|
122
|
+
raise ProcessSpawnError(
|
|
123
|
+
f"Unexpected error spawning {invoker.agent_id}: {e}"
|
|
124
|
+
) from e
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# =============================================================================
|
|
128
|
+
# Timeout Handling (T030)
|
|
129
|
+
# =============================================================================
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def execute_with_timeout(
|
|
133
|
+
process: Process,
|
|
134
|
+
stdin_data: bytes | None,
|
|
135
|
+
timeout_seconds: int,
|
|
136
|
+
) -> tuple[bytes, bytes, int]:
|
|
137
|
+
"""Wait for process with timeout, kill if exceeded.
|
|
138
|
+
|
|
139
|
+
Implements graceful shutdown: SIGTERM first, then SIGKILL if needed.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
process: Asyncio subprocess to wait on
|
|
143
|
+
stdin_data: Data to send to stdin (or None)
|
|
144
|
+
timeout_seconds: Maximum execution time
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Tuple of (stdout, stderr, exit_code)
|
|
148
|
+
Exit code is TIMEOUT_EXIT_CODE if timed out
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
stdout, stderr = await asyncio.wait_for(
|
|
152
|
+
process.communicate(input=stdin_data),
|
|
153
|
+
timeout=timeout_seconds,
|
|
154
|
+
)
|
|
155
|
+
return stdout, stderr, process.returncode or 0
|
|
156
|
+
|
|
157
|
+
except asyncio.TimeoutError:
|
|
158
|
+
logger.warning(
|
|
159
|
+
f"Process {process.pid} timed out after {timeout_seconds}s"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Graceful termination first
|
|
163
|
+
try:
|
|
164
|
+
process.terminate()
|
|
165
|
+
logger.debug(f"Sent SIGTERM to process {process.pid}")
|
|
166
|
+
|
|
167
|
+
# Wait for graceful shutdown
|
|
168
|
+
try:
|
|
169
|
+
await asyncio.wait_for(
|
|
170
|
+
process.wait(),
|
|
171
|
+
timeout=TERMINATION_GRACE_SECONDS,
|
|
172
|
+
)
|
|
173
|
+
logger.debug(f"Process {process.pid} terminated gracefully")
|
|
174
|
+
except asyncio.TimeoutError:
|
|
175
|
+
# Force kill if terminate didn't work
|
|
176
|
+
logger.warning(
|
|
177
|
+
f"Process {process.pid} didn't respond to SIGTERM, "
|
|
178
|
+
"sending SIGKILL"
|
|
179
|
+
)
|
|
180
|
+
process.kill()
|
|
181
|
+
await process.wait()
|
|
182
|
+
logger.debug(f"Process {process.pid} killed")
|
|
183
|
+
|
|
184
|
+
except ProcessLookupError:
|
|
185
|
+
# Process already dead
|
|
186
|
+
logger.debug(f"Process {process.pid} already terminated")
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
b"",
|
|
190
|
+
f"Execution timed out after {timeout_seconds} seconds".encode(),
|
|
191
|
+
TIMEOUT_EXIT_CODE,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# =============================================================================
|
|
196
|
+
# Stdin Piping (T028)
|
|
197
|
+
# =============================================================================
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def execute_agent(
|
|
201
|
+
invoker: AgentInvoker | BaseInvoker,
|
|
202
|
+
prompt_content: str,
|
|
203
|
+
working_dir: Path,
|
|
204
|
+
role: str,
|
|
205
|
+
timeout_seconds: int,
|
|
206
|
+
) -> InvocationResult:
|
|
207
|
+
"""Execute agent with prompt.
|
|
208
|
+
|
|
209
|
+
Spawns the agent process, pipes prompt via stdin if needed,
|
|
210
|
+
waits for completion with timeout, and parses output.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
invoker: Agent invoker that knows how to build commands
|
|
214
|
+
prompt_content: The task prompt content
|
|
215
|
+
working_dir: Directory where agent should execute
|
|
216
|
+
role: Either "implementation" or "review"
|
|
217
|
+
timeout_seconds: Maximum execution time
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
InvocationResult with parsed output
|
|
221
|
+
"""
|
|
222
|
+
# Spawn process
|
|
223
|
+
process, cmd = await spawn_agent(invoker, prompt_content, working_dir, role)
|
|
224
|
+
|
|
225
|
+
# Prepare stdin data if agent uses stdin
|
|
226
|
+
if invoker.uses_stdin:
|
|
227
|
+
stdin_data = prompt_content.encode("utf-8")
|
|
228
|
+
logger.debug(f"Piping {len(stdin_data)} bytes to stdin")
|
|
229
|
+
else:
|
|
230
|
+
stdin_data = None
|
|
231
|
+
logger.debug("Agent uses command-line args, no stdin")
|
|
232
|
+
|
|
233
|
+
# Execute with timeout
|
|
234
|
+
start_time = time.time()
|
|
235
|
+
stdout, stderr, exit_code = await execute_with_timeout(
|
|
236
|
+
process,
|
|
237
|
+
stdin_data,
|
|
238
|
+
timeout_seconds,
|
|
239
|
+
)
|
|
240
|
+
duration = time.time() - start_time
|
|
241
|
+
|
|
242
|
+
# Parse output
|
|
243
|
+
result = invoker.parse_output(
|
|
244
|
+
stdout.decode("utf-8", errors="replace"),
|
|
245
|
+
stderr.decode("utf-8", errors="replace"),
|
|
246
|
+
exit_code,
|
|
247
|
+
duration,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
logger.info(
|
|
251
|
+
f"{invoker.agent_id} completed: exit={exit_code}, "
|
|
252
|
+
f"duration={duration:.1f}s, success={result.success}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# =============================================================================
|
|
259
|
+
# Log File Capture (T029)
|
|
260
|
+
# =============================================================================
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_log_dir(repo_root: Path) -> Path:
|
|
264
|
+
"""Get logs directory under .kittify.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
repo_root: Repository root path
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Path to logs directory (created if needed)
|
|
271
|
+
"""
|
|
272
|
+
logs_dir = repo_root / ".kittify" / LOGS_DIRNAME
|
|
273
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
return logs_dir
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def get_log_path(
|
|
278
|
+
repo_root: Path,
|
|
279
|
+
wp_id: str,
|
|
280
|
+
role: str,
|
|
281
|
+
timestamp: datetime | None = None,
|
|
282
|
+
) -> Path:
|
|
283
|
+
"""Get path for agent log file.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
repo_root: Repository root path
|
|
287
|
+
wp_id: Work package ID (e.g., "WP01")
|
|
288
|
+
role: Either "implementation" or "review"
|
|
289
|
+
timestamp: Optional timestamp for uniqueness
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Path to log file
|
|
293
|
+
"""
|
|
294
|
+
logs_dir = get_log_dir(repo_root)
|
|
295
|
+
|
|
296
|
+
# Include timestamp for uniqueness (useful for retries)
|
|
297
|
+
if timestamp:
|
|
298
|
+
ts = timestamp.strftime("%Y%m%d-%H%M%S")
|
|
299
|
+
filename = f"{wp_id}-{role}-{ts}.log"
|
|
300
|
+
else:
|
|
301
|
+
filename = f"{wp_id}-{role}.log"
|
|
302
|
+
|
|
303
|
+
return logs_dir / filename
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def write_log_file(
|
|
307
|
+
log_path: Path,
|
|
308
|
+
agent_id: str,
|
|
309
|
+
role: str,
|
|
310
|
+
result: InvocationResult,
|
|
311
|
+
command: list[str] | None = None,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Write agent execution log to file.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
log_path: Path to write log file
|
|
317
|
+
agent_id: Agent identifier
|
|
318
|
+
role: Either "implementation" or "review"
|
|
319
|
+
result: Execution result
|
|
320
|
+
command: Optional command that was executed
|
|
321
|
+
"""
|
|
322
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
|
|
324
|
+
with open(log_path, "w") as f:
|
|
325
|
+
# Header
|
|
326
|
+
f.write("=" * 70 + "\n")
|
|
327
|
+
f.write(f"Agent: {agent_id}\n")
|
|
328
|
+
f.write(f"Role: {role}\n")
|
|
329
|
+
f.write(f"Exit code: {result.exit_code}\n")
|
|
330
|
+
f.write(f"Success: {result.success}\n")
|
|
331
|
+
f.write(f"Duration: {result.duration_seconds:.2f}s\n")
|
|
332
|
+
if command:
|
|
333
|
+
f.write(f"Command: {' '.join(command[:5])}...\n")
|
|
334
|
+
f.write("=" * 70 + "\n\n")
|
|
335
|
+
|
|
336
|
+
# Extracted data
|
|
337
|
+
if result.files_modified:
|
|
338
|
+
f.write("--- FILES MODIFIED ---\n")
|
|
339
|
+
for file in result.files_modified:
|
|
340
|
+
f.write(f" {file}\n")
|
|
341
|
+
f.write("\n")
|
|
342
|
+
|
|
343
|
+
if result.commits_made:
|
|
344
|
+
f.write("--- COMMITS ---\n")
|
|
345
|
+
for commit in result.commits_made:
|
|
346
|
+
f.write(f" {commit}\n")
|
|
347
|
+
f.write("\n")
|
|
348
|
+
|
|
349
|
+
if result.errors:
|
|
350
|
+
f.write("--- ERRORS ---\n")
|
|
351
|
+
for error in result.errors:
|
|
352
|
+
f.write(f" {error}\n")
|
|
353
|
+
f.write("\n")
|
|
354
|
+
|
|
355
|
+
if result.warnings:
|
|
356
|
+
f.write("--- WARNINGS ---\n")
|
|
357
|
+
for warning in result.warnings:
|
|
358
|
+
f.write(f" {warning}\n")
|
|
359
|
+
f.write("\n")
|
|
360
|
+
|
|
361
|
+
# Raw output
|
|
362
|
+
f.write("--- STDOUT ---\n")
|
|
363
|
+
f.write(result.stdout)
|
|
364
|
+
f.write("\n\n--- STDERR ---\n")
|
|
365
|
+
f.write(result.stderr)
|
|
366
|
+
|
|
367
|
+
logger.debug(f"Wrote log file: {log_path}")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def execute_with_logging(
|
|
371
|
+
invoker: AgentInvoker | BaseInvoker,
|
|
372
|
+
prompt_content: str,
|
|
373
|
+
working_dir: Path,
|
|
374
|
+
role: str,
|
|
375
|
+
timeout_seconds: int,
|
|
376
|
+
log_path: Path,
|
|
377
|
+
) -> InvocationResult:
|
|
378
|
+
"""Execute agent and save output to log file.
|
|
379
|
+
|
|
380
|
+
Combines execution with log file writing.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
invoker: Agent invoker
|
|
384
|
+
prompt_content: The task prompt content
|
|
385
|
+
working_dir: Directory where agent should execute
|
|
386
|
+
role: Either "implementation" or "review"
|
|
387
|
+
timeout_seconds: Maximum execution time
|
|
388
|
+
log_path: Path to write log file
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
InvocationResult with parsed output
|
|
392
|
+
"""
|
|
393
|
+
result = await execute_agent(
|
|
394
|
+
invoker,
|
|
395
|
+
prompt_content,
|
|
396
|
+
working_dir,
|
|
397
|
+
role,
|
|
398
|
+
timeout_seconds,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Write log file
|
|
402
|
+
write_log_file(log_path, invoker.agent_id, role, result)
|
|
403
|
+
|
|
404
|
+
return result
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# =============================================================================
|
|
408
|
+
# Worktree Creation (T031)
|
|
409
|
+
# =============================================================================
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
async def create_worktree(
|
|
413
|
+
feature_slug: str,
|
|
414
|
+
wp_id: str,
|
|
415
|
+
base_wp: str | None,
|
|
416
|
+
repo_root: Path,
|
|
417
|
+
) -> Path:
|
|
418
|
+
"""Create worktree for WP using spec-kitty CLI.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
feature_slug: Feature identifier (e.g., "020-feature-name")
|
|
422
|
+
wp_id: Work package ID (e.g., "WP01")
|
|
423
|
+
base_wp: Optional base WP for --base flag
|
|
424
|
+
repo_root: Repository root path
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Path to created worktree
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
WorktreeCreationError: If creation fails
|
|
431
|
+
"""
|
|
432
|
+
# Build command
|
|
433
|
+
cmd = ["spec-kitty", "implement", wp_id, "--feature", feature_slug]
|
|
434
|
+
if base_wp:
|
|
435
|
+
cmd.extend(["--base", base_wp])
|
|
436
|
+
|
|
437
|
+
logger.info(f"Creating worktree for {wp_id}: {' '.join(cmd)}")
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
process = await asyncio.create_subprocess_exec(
|
|
441
|
+
*cmd,
|
|
442
|
+
cwd=repo_root,
|
|
443
|
+
stdout=asyncio.subprocess.PIPE,
|
|
444
|
+
stderr=asyncio.subprocess.PIPE,
|
|
445
|
+
)
|
|
446
|
+
stdout, stderr = await process.communicate()
|
|
447
|
+
|
|
448
|
+
if process.returncode != 0:
|
|
449
|
+
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
|
450
|
+
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
|
451
|
+
# Combine both outputs for better error visibility
|
|
452
|
+
error_msg = stderr_text or stdout_text or "Unknown error (no output)"
|
|
453
|
+
raise WorktreeCreationError(
|
|
454
|
+
f"Failed to create worktree for {wp_id}: {error_msg}"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
except FileNotFoundError:
|
|
458
|
+
raise WorktreeCreationError(
|
|
459
|
+
"spec-kitty command not found. Is spec-kitty-cli installed?"
|
|
460
|
+
)
|
|
461
|
+
except Exception as e:
|
|
462
|
+
raise WorktreeCreationError(
|
|
463
|
+
f"Unexpected error creating worktree: {e}"
|
|
464
|
+
) from e
|
|
465
|
+
|
|
466
|
+
# Return worktree path
|
|
467
|
+
worktree_path = repo_root / ".worktrees" / f"{feature_slug}-{wp_id}"
|
|
468
|
+
|
|
469
|
+
if not worktree_path.exists():
|
|
470
|
+
raise WorktreeCreationError(
|
|
471
|
+
f"Worktree path {worktree_path} does not exist after creation"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
logger.info(f"Created worktree: {worktree_path}")
|
|
475
|
+
return worktree_path
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def get_worktree_path(
|
|
479
|
+
feature_slug: str,
|
|
480
|
+
wp_id: str,
|
|
481
|
+
repo_root: Path,
|
|
482
|
+
) -> Path:
|
|
483
|
+
"""Get expected worktree path for a WP.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
feature_slug: Feature identifier
|
|
487
|
+
wp_id: Work package ID
|
|
488
|
+
repo_root: Repository root path
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
Expected path to worktree
|
|
492
|
+
"""
|
|
493
|
+
return repo_root / ".worktrees" / f"{feature_slug}-{wp_id}"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def worktree_exists(
|
|
497
|
+
feature_slug: str,
|
|
498
|
+
wp_id: str,
|
|
499
|
+
repo_root: Path,
|
|
500
|
+
) -> bool:
|
|
501
|
+
"""Check if worktree already exists.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
feature_slug: Feature identifier
|
|
505
|
+
wp_id: Work package ID
|
|
506
|
+
repo_root: Repository root path
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
True if worktree directory exists
|
|
510
|
+
"""
|
|
511
|
+
return get_worktree_path(feature_slug, wp_id, repo_root).exists()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# =============================================================================
|
|
515
|
+
# Full Execution Pipeline
|
|
516
|
+
# =============================================================================
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@dataclass
|
|
520
|
+
class ExecutionContext:
|
|
521
|
+
"""Context for a single WP execution.
|
|
522
|
+
|
|
523
|
+
Groups together all parameters needed for execution.
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
wp_id: str
|
|
527
|
+
feature_slug: str
|
|
528
|
+
invoker: AgentInvoker | BaseInvoker
|
|
529
|
+
prompt_path: Path
|
|
530
|
+
role: str
|
|
531
|
+
timeout_seconds: int
|
|
532
|
+
repo_root: Path
|
|
533
|
+
working_dir: Path | None = None # Set after worktree creation
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
async def execute_wp(
|
|
537
|
+
ctx: ExecutionContext,
|
|
538
|
+
create_worktree_if_missing: bool = True,
|
|
539
|
+
base_wp: str | None = None,
|
|
540
|
+
) -> tuple[InvocationResult, Path]:
|
|
541
|
+
"""Execute a complete WP implementation or review.
|
|
542
|
+
|
|
543
|
+
This is the main entry point for WP execution. It:
|
|
544
|
+
1. Creates worktree if needed
|
|
545
|
+
2. Reads prompt file
|
|
546
|
+
3. Executes agent with logging
|
|
547
|
+
4. Returns result and log path
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
ctx: Execution context with all parameters
|
|
551
|
+
create_worktree_if_missing: Whether to create worktree if not exists
|
|
552
|
+
base_wp: Base WP for --base flag when creating worktree
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
Tuple of (InvocationResult, log_path)
|
|
556
|
+
|
|
557
|
+
Raises:
|
|
558
|
+
WorktreeCreationError: If worktree creation fails
|
|
559
|
+
ProcessSpawnError: If agent spawn fails
|
|
560
|
+
FileNotFoundError: If prompt file doesn't exist
|
|
561
|
+
"""
|
|
562
|
+
# Get or create worktree
|
|
563
|
+
if ctx.working_dir:
|
|
564
|
+
working_dir = ctx.working_dir
|
|
565
|
+
else:
|
|
566
|
+
worktree_path = get_worktree_path(
|
|
567
|
+
ctx.feature_slug,
|
|
568
|
+
ctx.wp_id,
|
|
569
|
+
ctx.repo_root,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
if not worktree_path.exists():
|
|
573
|
+
if create_worktree_if_missing:
|
|
574
|
+
worktree_path = await create_worktree(
|
|
575
|
+
ctx.feature_slug,
|
|
576
|
+
ctx.wp_id,
|
|
577
|
+
base_wp,
|
|
578
|
+
ctx.repo_root,
|
|
579
|
+
)
|
|
580
|
+
else:
|
|
581
|
+
raise WorktreeCreationError(
|
|
582
|
+
f"Worktree {worktree_path} does not exist"
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
working_dir = worktree_path
|
|
586
|
+
|
|
587
|
+
# Read prompt content
|
|
588
|
+
if not ctx.prompt_path.exists():
|
|
589
|
+
raise FileNotFoundError(f"Prompt file not found: {ctx.prompt_path}")
|
|
590
|
+
|
|
591
|
+
prompt_content = ctx.prompt_path.read_text()
|
|
592
|
+
|
|
593
|
+
# Get log path
|
|
594
|
+
log_path = get_log_path(
|
|
595
|
+
ctx.repo_root,
|
|
596
|
+
ctx.wp_id,
|
|
597
|
+
ctx.role,
|
|
598
|
+
timestamp=datetime.now(),
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Execute with logging
|
|
602
|
+
result = await execute_with_logging(
|
|
603
|
+
ctx.invoker,
|
|
604
|
+
prompt_content,
|
|
605
|
+
working_dir,
|
|
606
|
+
ctx.role,
|
|
607
|
+
ctx.timeout_seconds,
|
|
608
|
+
log_path,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
return result, log_path
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
__all__ = [
|
|
615
|
+
# Constants
|
|
616
|
+
"TIMEOUT_EXIT_CODE",
|
|
617
|
+
"TERMINATION_GRACE_SECONDS",
|
|
618
|
+
"LOGS_DIRNAME",
|
|
619
|
+
# Exceptions
|
|
620
|
+
"ExecutorError",
|
|
621
|
+
"WorktreeCreationError",
|
|
622
|
+
"ProcessSpawnError",
|
|
623
|
+
"TimeoutError",
|
|
624
|
+
# Process spawning (T027)
|
|
625
|
+
"spawn_agent",
|
|
626
|
+
# Stdin piping (T028)
|
|
627
|
+
"execute_agent",
|
|
628
|
+
# Log capture (T029)
|
|
629
|
+
"get_log_dir",
|
|
630
|
+
"get_log_path",
|
|
631
|
+
"write_log_file",
|
|
632
|
+
"execute_with_logging",
|
|
633
|
+
# Timeout handling (T030)
|
|
634
|
+
"execute_with_timeout",
|
|
635
|
+
# Worktree integration (T031)
|
|
636
|
+
"create_worktree",
|
|
637
|
+
"get_worktree_path",
|
|
638
|
+
"worktree_exists",
|
|
639
|
+
# Full pipeline
|
|
640
|
+
"ExecutionContext",
|
|
641
|
+
"execute_wp",
|
|
642
|
+
]
|