galangal-orchestrate 0.2.11__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.
Potentially problematic release.
This version of galangal-orchestrate might be problematic. Click here for more details.
- galangal/__init__.py +8 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +6 -0
- galangal/ai/base.py +55 -0
- galangal/ai/claude.py +278 -0
- galangal/ai/gemini.py +38 -0
- galangal/cli.py +296 -0
- galangal/commands/__init__.py +42 -0
- galangal/commands/approve.py +187 -0
- galangal/commands/complete.py +268 -0
- galangal/commands/init.py +173 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +40 -0
- galangal/commands/prompts.py +98 -0
- galangal/commands/reset.py +43 -0
- galangal/commands/resume.py +29 -0
- galangal/commands/skip.py +216 -0
- galangal/commands/start.py +144 -0
- galangal/commands/status.py +62 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +13 -0
- galangal/config/defaults.py +133 -0
- galangal/config/loader.py +113 -0
- galangal/config/schema.py +155 -0
- galangal/core/__init__.py +18 -0
- galangal/core/artifacts.py +66 -0
- galangal/core/state.py +248 -0
- galangal/core/tasks.py +170 -0
- galangal/core/workflow.py +835 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +166 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +39 -0
- galangal/prompts/defaults/docs.md +46 -0
- galangal/prompts/defaults/pm.md +75 -0
- galangal/prompts/defaults/qa.md +49 -0
- galangal/prompts/defaults/review.md +65 -0
- galangal/prompts/defaults/security.md +68 -0
- galangal/prompts/defaults/test.md +59 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +123 -0
- galangal/ui/tui.py +1065 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +395 -0
- galangal_orchestrate-0.2.11.dist-info/METADATA +278 -0
- galangal_orchestrate-0.2.11.dist-info/RECORD +49 -0
- galangal_orchestrate-0.2.11.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.2.11.dist-info/entry_points.txt +2 -0
- galangal_orchestrate-0.2.11.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow execution - stage execution, rollback, loop handling.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import signal
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from galangal.config.loader import get_config, get_tasks_dir
|
|
12
|
+
from galangal.core.artifacts import artifact_exists, read_artifact, write_artifact, artifact_path
|
|
13
|
+
from galangal.core.state import (
|
|
14
|
+
Stage,
|
|
15
|
+
WorkflowState,
|
|
16
|
+
STAGE_ORDER,
|
|
17
|
+
save_state,
|
|
18
|
+
get_task_dir,
|
|
19
|
+
should_skip_for_task_type,
|
|
20
|
+
get_hidden_stages_for_task_type,
|
|
21
|
+
)
|
|
22
|
+
from galangal.core.tasks import get_current_branch
|
|
23
|
+
from galangal.ai.claude import set_pause_requested, get_pause_requested
|
|
24
|
+
from galangal.prompts.builder import PromptBuilder
|
|
25
|
+
from galangal.validation.runner import ValidationRunner
|
|
26
|
+
from galangal.ui.tui import run_stage_with_tui, TUIAdapter, WorkflowTUIApp, PromptType
|
|
27
|
+
from galangal.ai.claude import ClaudeBackend
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
# Global state for pause handling
|
|
32
|
+
_current_state: Optional[WorkflowState] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _signal_handler(signum: int, frame) -> None:
|
|
36
|
+
"""Handle SIGINT (Ctrl+C) gracefully."""
|
|
37
|
+
set_pause_requested(True)
|
|
38
|
+
console.print(
|
|
39
|
+
"\n\n[yellow]⏸️ Pause requested - finishing current operation...[/yellow]"
|
|
40
|
+
)
|
|
41
|
+
console.print("[dim] (Press Ctrl+C again to force quit)[/dim]\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_next_stage(
|
|
45
|
+
current: Stage, state: WorkflowState
|
|
46
|
+
) -> Optional[Stage]:
|
|
47
|
+
"""Get the next stage, handling conditional stages and task type skipping."""
|
|
48
|
+
config = get_config()
|
|
49
|
+
task_name = state.task_name
|
|
50
|
+
task_type = state.task_type
|
|
51
|
+
idx = STAGE_ORDER.index(current)
|
|
52
|
+
|
|
53
|
+
if idx >= len(STAGE_ORDER) - 1:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
next_stage = STAGE_ORDER[idx + 1]
|
|
57
|
+
|
|
58
|
+
# Check config-level skipping
|
|
59
|
+
if next_stage.value in [s.upper() for s in config.stages.skip]:
|
|
60
|
+
return get_next_stage(next_stage, state)
|
|
61
|
+
|
|
62
|
+
# Check task type skipping
|
|
63
|
+
if should_skip_for_task_type(next_stage, task_type):
|
|
64
|
+
return get_next_stage(next_stage, state)
|
|
65
|
+
|
|
66
|
+
# Check conditional stages via validation runner
|
|
67
|
+
runner = ValidationRunner()
|
|
68
|
+
if next_stage == Stage.MIGRATION:
|
|
69
|
+
if artifact_exists("MIGRATION_SKIP.md", task_name):
|
|
70
|
+
return get_next_stage(next_stage, state)
|
|
71
|
+
# Check skip condition
|
|
72
|
+
result = runner.validate_stage("MIGRATION", task_name)
|
|
73
|
+
if result.message.endswith("(condition met)"):
|
|
74
|
+
return get_next_stage(next_stage, state)
|
|
75
|
+
|
|
76
|
+
elif next_stage == Stage.CONTRACT:
|
|
77
|
+
if artifact_exists("CONTRACT_SKIP.md", task_name):
|
|
78
|
+
return get_next_stage(next_stage, state)
|
|
79
|
+
result = runner.validate_stage("CONTRACT", task_name)
|
|
80
|
+
if result.message.endswith("(condition met)"):
|
|
81
|
+
return get_next_stage(next_stage, state)
|
|
82
|
+
|
|
83
|
+
elif next_stage == Stage.BENCHMARK:
|
|
84
|
+
if artifact_exists("BENCHMARK_SKIP.md", task_name):
|
|
85
|
+
return get_next_stage(next_stage, state)
|
|
86
|
+
result = runner.validate_stage("BENCHMARK", task_name)
|
|
87
|
+
if result.message.endswith("(condition met)"):
|
|
88
|
+
return get_next_stage(next_stage, state)
|
|
89
|
+
|
|
90
|
+
return next_stage
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def execute_stage(state: WorkflowState, tui_app: WorkflowTUIApp = None) -> tuple[bool, str]:
|
|
94
|
+
"""Execute the current stage. Returns (success, message).
|
|
95
|
+
|
|
96
|
+
If tui_app is provided, uses the persistent TUI instead of creating a new one.
|
|
97
|
+
"""
|
|
98
|
+
stage = state.stage
|
|
99
|
+
task_name = state.task_name
|
|
100
|
+
config = get_config()
|
|
101
|
+
|
|
102
|
+
if stage == Stage.COMPLETE:
|
|
103
|
+
return True, "Workflow complete"
|
|
104
|
+
|
|
105
|
+
# Design approval gate (only in legacy mode without TUI)
|
|
106
|
+
if stage == Stage.DEV and tui_app is None:
|
|
107
|
+
from galangal.commands.approve import prompt_design_approval
|
|
108
|
+
design_skipped = artifact_exists(
|
|
109
|
+
"DESIGN_SKIP.md", task_name
|
|
110
|
+
) or should_skip_for_task_type(Stage.DESIGN, state.task_type)
|
|
111
|
+
|
|
112
|
+
if design_skipped:
|
|
113
|
+
pass
|
|
114
|
+
elif not artifact_exists("DESIGN_REVIEW.md", task_name):
|
|
115
|
+
result = prompt_design_approval(task_name, state)
|
|
116
|
+
if result == "quit":
|
|
117
|
+
return False, "PAUSED: User requested pause"
|
|
118
|
+
elif result == "rejected":
|
|
119
|
+
return True, "Design rejected - restarting from DESIGN"
|
|
120
|
+
|
|
121
|
+
# Check for clarification
|
|
122
|
+
if artifact_exists("QUESTIONS.md", task_name) and not artifact_exists(
|
|
123
|
+
"ANSWERS.md", task_name
|
|
124
|
+
):
|
|
125
|
+
state.clarification_required = True
|
|
126
|
+
save_state(state)
|
|
127
|
+
return False, "Clarification required. Please provide ANSWERS.md."
|
|
128
|
+
|
|
129
|
+
# PREFLIGHT runs validation directly
|
|
130
|
+
if stage == Stage.PREFLIGHT:
|
|
131
|
+
if tui_app:
|
|
132
|
+
tui_app.add_activity("Running preflight checks...", "⚙")
|
|
133
|
+
else:
|
|
134
|
+
console.print("[dim]Running preflight checks...[/dim]")
|
|
135
|
+
|
|
136
|
+
runner = ValidationRunner()
|
|
137
|
+
result = runner.validate_stage("PREFLIGHT", task_name)
|
|
138
|
+
|
|
139
|
+
if result.success:
|
|
140
|
+
if tui_app:
|
|
141
|
+
tui_app.show_message(f"Preflight: {result.message}", "success")
|
|
142
|
+
else:
|
|
143
|
+
console.print(f"[green]✓ Preflight: {result.message}[/green]")
|
|
144
|
+
return True, result.message
|
|
145
|
+
else:
|
|
146
|
+
# Include detailed output in the failure message for display
|
|
147
|
+
detailed_message = result.message
|
|
148
|
+
if result.output:
|
|
149
|
+
detailed_message = f"{result.message}\n\n{result.output}"
|
|
150
|
+
# Return special marker so workflow knows this is preflight failure
|
|
151
|
+
return False, f"PREFLIGHT_FAILED:{detailed_message}"
|
|
152
|
+
|
|
153
|
+
# Get current branch for UI
|
|
154
|
+
branch = get_current_branch()
|
|
155
|
+
|
|
156
|
+
# Build prompt
|
|
157
|
+
builder = PromptBuilder()
|
|
158
|
+
prompt = builder.build_full_prompt(stage, state)
|
|
159
|
+
|
|
160
|
+
# Add retry context
|
|
161
|
+
if state.attempt > 1 and state.last_failure:
|
|
162
|
+
retry_context = f"""
|
|
163
|
+
## ⚠️ RETRY ATTEMPT {state.attempt}
|
|
164
|
+
|
|
165
|
+
The previous attempt failed with the following error:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
{state.last_failure[:1000]}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Please fix the issue above before proceeding. Do not repeat the same mistake.
|
|
172
|
+
"""
|
|
173
|
+
prompt = f"{prompt}\n\n{retry_context}"
|
|
174
|
+
|
|
175
|
+
# Log the prompt
|
|
176
|
+
logs_dir = get_task_dir(task_name) / "logs"
|
|
177
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
log_file = logs_dir / f"{stage.value.lower()}_{state.attempt}.log"
|
|
179
|
+
with open(log_file, "w") as f:
|
|
180
|
+
f.write(f"=== Prompt ===\n{prompt}\n\n")
|
|
181
|
+
|
|
182
|
+
# Run stage - either with persistent TUI or standalone
|
|
183
|
+
if tui_app:
|
|
184
|
+
# Use the persistent TUI
|
|
185
|
+
backend = ClaudeBackend()
|
|
186
|
+
ui = TUIAdapter(tui_app)
|
|
187
|
+
success, output = backend.invoke(
|
|
188
|
+
prompt=prompt,
|
|
189
|
+
timeout=14400,
|
|
190
|
+
max_turns=200,
|
|
191
|
+
ui=ui,
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
# Create new TUI for this stage
|
|
195
|
+
success, output = run_stage_with_tui(
|
|
196
|
+
task_name=task_name,
|
|
197
|
+
stage=stage.value,
|
|
198
|
+
branch=branch,
|
|
199
|
+
attempt=state.attempt,
|
|
200
|
+
prompt=prompt,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Log the output
|
|
204
|
+
with open(log_file, "a") as f:
|
|
205
|
+
f.write(f"=== Output ===\n{output}\n")
|
|
206
|
+
|
|
207
|
+
if not success:
|
|
208
|
+
return False, output
|
|
209
|
+
|
|
210
|
+
# Validate stage
|
|
211
|
+
if tui_app:
|
|
212
|
+
tui_app.add_activity("Validating stage outputs...", "⚙")
|
|
213
|
+
else:
|
|
214
|
+
console.print("[dim]Validating stage outputs...[/dim]")
|
|
215
|
+
|
|
216
|
+
runner = ValidationRunner()
|
|
217
|
+
result = runner.validate_stage(stage.value, task_name)
|
|
218
|
+
|
|
219
|
+
with open(log_file, "a") as f:
|
|
220
|
+
f.write(f"\n=== Validation ===\n{result.message}\n")
|
|
221
|
+
|
|
222
|
+
if result.success:
|
|
223
|
+
if tui_app:
|
|
224
|
+
tui_app.show_message(result.message, "success")
|
|
225
|
+
else:
|
|
226
|
+
console.print(f"[green]✓ {result.message}[/green]")
|
|
227
|
+
else:
|
|
228
|
+
if tui_app:
|
|
229
|
+
tui_app.show_message(result.message, "error")
|
|
230
|
+
else:
|
|
231
|
+
console.print(f"[red]✗ {result.message}[/red]")
|
|
232
|
+
|
|
233
|
+
# Include rollback target in message if validation failed
|
|
234
|
+
if not result.success and result.rollback_to:
|
|
235
|
+
return False, f"ROLLBACK:{result.rollback_to}:{result.message}"
|
|
236
|
+
|
|
237
|
+
return result.success, result.message
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def archive_rollback_if_exists(task_name: str) -> None:
|
|
241
|
+
"""Archive ROLLBACK.md after DEV stage succeeds."""
|
|
242
|
+
if not artifact_exists("ROLLBACK.md", task_name):
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
rollback_content = read_artifact("ROLLBACK.md", task_name)
|
|
246
|
+
resolved_path = artifact_path("ROLLBACK_RESOLVED.md", task_name)
|
|
247
|
+
|
|
248
|
+
resolution_note = f"\n\n## Resolved: {datetime.now(timezone.utc).isoformat()}\n\nIssues fixed by DEV stage.\n"
|
|
249
|
+
|
|
250
|
+
if resolved_path.exists():
|
|
251
|
+
existing = resolved_path.read_text()
|
|
252
|
+
resolved_path.write_text(
|
|
253
|
+
existing + "\n---\n" + rollback_content + resolution_note
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
resolved_path.write_text(rollback_content + resolution_note)
|
|
257
|
+
|
|
258
|
+
rollback_path = artifact_path("ROLLBACK.md", task_name)
|
|
259
|
+
rollback_path.unlink()
|
|
260
|
+
|
|
261
|
+
console.print(" [dim]📋 Archived ROLLBACK.md → ROLLBACK_RESOLVED.md[/dim]")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def handle_rollback(state: WorkflowState, message: str) -> bool:
|
|
265
|
+
"""Handle a rollback signal from a stage validator."""
|
|
266
|
+
runner = ValidationRunner()
|
|
267
|
+
|
|
268
|
+
# Check for rollback_to in validation result
|
|
269
|
+
if not message.startswith("ROLLBACK:") and "rollback" not in message.lower():
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
# Parse rollback target
|
|
273
|
+
target_stage = Stage.DEV # Default rollback target
|
|
274
|
+
|
|
275
|
+
if message.startswith("ROLLBACK:"):
|
|
276
|
+
parts = message.split(":", 2)
|
|
277
|
+
if len(parts) >= 2:
|
|
278
|
+
try:
|
|
279
|
+
target_stage = Stage.from_str(parts[1])
|
|
280
|
+
except ValueError:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
task_name = state.task_name
|
|
284
|
+
from_stage = state.stage
|
|
285
|
+
|
|
286
|
+
reason = message.split(":", 2)[-1] if ":" in message else message
|
|
287
|
+
|
|
288
|
+
rollback_entry = f"""
|
|
289
|
+
## Rollback from {from_stage.value}
|
|
290
|
+
|
|
291
|
+
**Date:** {datetime.now(timezone.utc).isoformat()}
|
|
292
|
+
**From Stage:** {from_stage.value}
|
|
293
|
+
**Target Stage:** {target_stage.value}
|
|
294
|
+
**Reason:** {reason}
|
|
295
|
+
|
|
296
|
+
### Required Actions
|
|
297
|
+
{reason}
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
existing = read_artifact("ROLLBACK.md", task_name)
|
|
303
|
+
if existing:
|
|
304
|
+
new_content = existing + rollback_entry
|
|
305
|
+
else:
|
|
306
|
+
new_content = f"# Rollback Log\n\nThis file tracks issues that required rolling back to earlier stages.\n{rollback_entry}"
|
|
307
|
+
|
|
308
|
+
write_artifact("ROLLBACK.md", new_content, task_name)
|
|
309
|
+
|
|
310
|
+
state.stage = target_stage
|
|
311
|
+
state.attempt = 1
|
|
312
|
+
state.last_failure = f"Rollback from {from_stage.value}: {reason}"
|
|
313
|
+
save_state(state)
|
|
314
|
+
|
|
315
|
+
console.print(
|
|
316
|
+
f"\n[yellow]⚠️ ROLLBACK: {from_stage.value} → {target_stage.value}[/yellow]"
|
|
317
|
+
)
|
|
318
|
+
console.print(f" Reason: {reason}")
|
|
319
|
+
console.print(" See ROLLBACK.md for details\n")
|
|
320
|
+
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def run_workflow(state: WorkflowState) -> None:
|
|
325
|
+
"""Run the workflow from current state to completion or failure."""
|
|
326
|
+
import os
|
|
327
|
+
import threading
|
|
328
|
+
|
|
329
|
+
# Try persistent TUI first (unless disabled)
|
|
330
|
+
if not os.environ.get("GALANGAL_NO_TUI"):
|
|
331
|
+
try:
|
|
332
|
+
result = _run_workflow_with_tui(state)
|
|
333
|
+
if result != "use_legacy":
|
|
334
|
+
# TUI handled the workflow
|
|
335
|
+
return
|
|
336
|
+
except Exception as e:
|
|
337
|
+
console.print(f"[yellow]TUI error: {e}. Using legacy mode.[/yellow]")
|
|
338
|
+
|
|
339
|
+
# Legacy mode (no persistent TUI)
|
|
340
|
+
_run_workflow_legacy(state)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _run_workflow_with_tui(state: WorkflowState) -> str:
|
|
344
|
+
"""Run workflow with persistent TUI. Returns result or 'use_legacy' to fall back."""
|
|
345
|
+
import threading
|
|
346
|
+
|
|
347
|
+
config = get_config()
|
|
348
|
+
max_retries = config.stages.max_retries
|
|
349
|
+
|
|
350
|
+
# Compute hidden stages based on task type and config
|
|
351
|
+
hidden_stages = frozenset(
|
|
352
|
+
get_hidden_stages_for_task_type(state.task_type, config.stages.skip)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
app = WorkflowTUIApp(
|
|
356
|
+
state.task_name,
|
|
357
|
+
state.stage.value,
|
|
358
|
+
hidden_stages=hidden_stages,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Shared state for thread communication
|
|
362
|
+
workflow_done = threading.Event()
|
|
363
|
+
|
|
364
|
+
def workflow_thread():
|
|
365
|
+
"""Run the workflow loop in a background thread."""
|
|
366
|
+
try:
|
|
367
|
+
while state.stage != Stage.COMPLETE and not app._paused:
|
|
368
|
+
app.update_stage(state.stage.value, state.attempt)
|
|
369
|
+
app.set_status("running", f"executing {state.stage.value}")
|
|
370
|
+
|
|
371
|
+
# Execute stage with the TUI app
|
|
372
|
+
success, message = execute_stage(state, tui_app=app)
|
|
373
|
+
|
|
374
|
+
if app._paused:
|
|
375
|
+
app._workflow_result = "paused"
|
|
376
|
+
break
|
|
377
|
+
|
|
378
|
+
if not success:
|
|
379
|
+
app.show_stage_complete(state.stage.value, False)
|
|
380
|
+
|
|
381
|
+
# Handle preflight failures specially - don't auto-retry
|
|
382
|
+
if message.startswith("PREFLIGHT_FAILED:"):
|
|
383
|
+
detailed_error = message[len("PREFLIGHT_FAILED:") :]
|
|
384
|
+
app.show_message("Preflight checks failed", "error")
|
|
385
|
+
# Show the detailed report in the activity log
|
|
386
|
+
for line in detailed_error.strip().split("\n"):
|
|
387
|
+
if line.strip():
|
|
388
|
+
app.add_activity(line)
|
|
389
|
+
|
|
390
|
+
# Prompt user to retry after fixing
|
|
391
|
+
retry_event = threading.Event()
|
|
392
|
+
retry_result = {"value": None}
|
|
393
|
+
|
|
394
|
+
def handle_preflight_retry(choice):
|
|
395
|
+
retry_result["value"] = choice
|
|
396
|
+
retry_event.set()
|
|
397
|
+
|
|
398
|
+
app.show_prompt(
|
|
399
|
+
PromptType.PREFLIGHT_RETRY,
|
|
400
|
+
"Fix environment issues and retry?",
|
|
401
|
+
handle_preflight_retry,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
retry_event.wait()
|
|
405
|
+
if retry_result["value"] == "retry":
|
|
406
|
+
app.show_message("Retrying preflight checks...", "info")
|
|
407
|
+
continue # Retry without incrementing attempt
|
|
408
|
+
else:
|
|
409
|
+
save_state(state)
|
|
410
|
+
app._workflow_result = "paused"
|
|
411
|
+
break
|
|
412
|
+
|
|
413
|
+
if state.awaiting_approval or state.clarification_required:
|
|
414
|
+
app.show_message(message, "warning")
|
|
415
|
+
save_state(state)
|
|
416
|
+
app._workflow_result = "paused"
|
|
417
|
+
break
|
|
418
|
+
|
|
419
|
+
if handle_rollback(state, message):
|
|
420
|
+
app.show_message(f"Rolling back: {message[:80]}", "warning")
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
state.attempt += 1
|
|
424
|
+
state.last_failure = message
|
|
425
|
+
|
|
426
|
+
if state.attempt > max_retries:
|
|
427
|
+
app.show_message(f"Max retries ({max_retries}) exceeded for {state.stage.value}", "error")
|
|
428
|
+
# Show the failure details in the activity log
|
|
429
|
+
app.add_activity("")
|
|
430
|
+
app.add_activity("[bold red]Last failure:[/bold red]")
|
|
431
|
+
for line in message[:2000].split("\n"):
|
|
432
|
+
if line.strip():
|
|
433
|
+
app.add_activity(f" {line}")
|
|
434
|
+
|
|
435
|
+
# Prompt user for what to do
|
|
436
|
+
failure_event = threading.Event()
|
|
437
|
+
failure_result = {"value": None, "feedback": None}
|
|
438
|
+
|
|
439
|
+
def handle_failure_choice(choice):
|
|
440
|
+
failure_result["value"] = choice
|
|
441
|
+
if choice == "fix_in_dev":
|
|
442
|
+
# Need to get feedback first
|
|
443
|
+
def handle_feedback(feedback):
|
|
444
|
+
failure_result["feedback"] = feedback
|
|
445
|
+
failure_event.set()
|
|
446
|
+
|
|
447
|
+
app.show_text_input(
|
|
448
|
+
"Describe what needs to be fixed:",
|
|
449
|
+
handle_feedback,
|
|
450
|
+
)
|
|
451
|
+
else:
|
|
452
|
+
failure_event.set()
|
|
453
|
+
|
|
454
|
+
app.show_prompt(
|
|
455
|
+
PromptType.STAGE_FAILURE,
|
|
456
|
+
f"Stage {state.stage.value} failed after {max_retries} attempts. What would you like to do?",
|
|
457
|
+
handle_failure_choice,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
failure_event.wait()
|
|
461
|
+
|
|
462
|
+
if failure_result["value"] == "retry":
|
|
463
|
+
state.attempt = 1 # Reset attempts
|
|
464
|
+
app.show_message("Retrying stage...", "info")
|
|
465
|
+
save_state(state)
|
|
466
|
+
continue
|
|
467
|
+
elif failure_result["value"] == "fix_in_dev":
|
|
468
|
+
feedback = failure_result["feedback"] or "Fix the failing stage"
|
|
469
|
+
# Roll back to DEV with feedback
|
|
470
|
+
failing_stage = state.stage.value
|
|
471
|
+
state.stage = Stage.DEV
|
|
472
|
+
state.attempt = 1
|
|
473
|
+
state.last_failure = f"Feedback from {failing_stage} failure: {feedback}\n\nOriginal error:\n{message[:1500]}"
|
|
474
|
+
app.show_message(f"Rolling back to DEV with feedback", "warning")
|
|
475
|
+
save_state(state)
|
|
476
|
+
continue
|
|
477
|
+
else:
|
|
478
|
+
save_state(state)
|
|
479
|
+
app._workflow_result = "paused"
|
|
480
|
+
break
|
|
481
|
+
|
|
482
|
+
app.show_message(f"Retrying (attempt {state.attempt}/{max_retries})...", "warning")
|
|
483
|
+
save_state(state)
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
app.show_stage_complete(state.stage.value, True)
|
|
487
|
+
|
|
488
|
+
# Plan approval gate - handle in TUI with two-step flow
|
|
489
|
+
if state.stage == Stage.PM and not artifact_exists("APPROVAL.md", state.task_name):
|
|
490
|
+
approval_event = threading.Event()
|
|
491
|
+
approval_result = {"value": None, "approver": None, "reason": None}
|
|
492
|
+
|
|
493
|
+
# Get default approver name from config
|
|
494
|
+
default_approver = config.project.approver_name or ""
|
|
495
|
+
|
|
496
|
+
def handle_approval(choice):
|
|
497
|
+
if choice == "yes":
|
|
498
|
+
approval_result["value"] = "pending_name"
|
|
499
|
+
# Don't set event yet - wait for name input
|
|
500
|
+
elif choice == "no":
|
|
501
|
+
approval_result["value"] = "pending_reason"
|
|
502
|
+
# Don't set event yet - wait for rejection reason
|
|
503
|
+
else:
|
|
504
|
+
approval_result["value"] = "quit"
|
|
505
|
+
approval_event.set()
|
|
506
|
+
|
|
507
|
+
def handle_approver_name(name):
|
|
508
|
+
if name:
|
|
509
|
+
approval_result["value"] = "approved"
|
|
510
|
+
approval_result["approver"] = name
|
|
511
|
+
# Write approval artifact with approver name
|
|
512
|
+
from datetime import datetime, timezone
|
|
513
|
+
approval_content = f"""# Plan Approval
|
|
514
|
+
|
|
515
|
+
- **Status:** Approved
|
|
516
|
+
- **Approved By:** {name}
|
|
517
|
+
- **Date:** {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
|
|
518
|
+
"""
|
|
519
|
+
write_artifact("APPROVAL.md", approval_content, state.task_name)
|
|
520
|
+
app.show_message(f"Plan approved by {name}", "success")
|
|
521
|
+
else:
|
|
522
|
+
# Cancelled - go back to approval prompt
|
|
523
|
+
approval_result["value"] = None
|
|
524
|
+
app.show_prompt(PromptType.PLAN_APPROVAL, "Approve plan to continue?", handle_approval)
|
|
525
|
+
return # Don't set event - wait for new choice
|
|
526
|
+
approval_event.set()
|
|
527
|
+
|
|
528
|
+
def handle_rejection_reason(reason):
|
|
529
|
+
if reason:
|
|
530
|
+
approval_result["value"] = "rejected"
|
|
531
|
+
approval_result["reason"] = reason
|
|
532
|
+
state.stage = Stage.PM
|
|
533
|
+
state.attempt = 1
|
|
534
|
+
state.last_failure = f"Plan rejected: {reason}"
|
|
535
|
+
save_state(state)
|
|
536
|
+
app.show_message(f"Plan rejected: {reason}", "warning")
|
|
537
|
+
else:
|
|
538
|
+
# Cancelled - go back to approval prompt
|
|
539
|
+
approval_result["value"] = None
|
|
540
|
+
app.show_prompt(PromptType.PLAN_APPROVAL, "Approve plan to continue?", handle_approval)
|
|
541
|
+
return # Don't set event - wait for new choice
|
|
542
|
+
approval_event.set()
|
|
543
|
+
|
|
544
|
+
app.show_prompt(PromptType.PLAN_APPROVAL, "Approve plan to continue?", handle_approval)
|
|
545
|
+
|
|
546
|
+
# Wait for choice, then potentially for name or reason
|
|
547
|
+
while not approval_event.is_set():
|
|
548
|
+
approval_event.wait(timeout=0.1)
|
|
549
|
+
if approval_result["value"] == "pending_name":
|
|
550
|
+
approval_result["value"] = None # Reset
|
|
551
|
+
app.show_text_input("Enter approver name:", default_approver, handle_approver_name)
|
|
552
|
+
elif approval_result["value"] == "pending_reason":
|
|
553
|
+
approval_result["value"] = None # Reset
|
|
554
|
+
app.show_text_input("Enter rejection reason:", "Needs revision", handle_rejection_reason)
|
|
555
|
+
|
|
556
|
+
if approval_result["value"] == "quit":
|
|
557
|
+
app._workflow_result = "paused"
|
|
558
|
+
break
|
|
559
|
+
elif approval_result["value"] == "rejected":
|
|
560
|
+
app.show_message("Restarting PM stage with feedback...", "info")
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
if state.stage == Stage.DEV:
|
|
564
|
+
archive_rollback_if_exists(state.task_name)
|
|
565
|
+
|
|
566
|
+
# Get next stage
|
|
567
|
+
next_stage = get_next_stage(state.stage, state)
|
|
568
|
+
if next_stage:
|
|
569
|
+
expected_next_idx = STAGE_ORDER.index(state.stage) + 1
|
|
570
|
+
actual_next_idx = STAGE_ORDER.index(next_stage)
|
|
571
|
+
if actual_next_idx > expected_next_idx:
|
|
572
|
+
skipped = STAGE_ORDER[expected_next_idx:actual_next_idx]
|
|
573
|
+
for s in skipped:
|
|
574
|
+
app.show_message(f"Skipped {s.value} (condition not met)", "info")
|
|
575
|
+
|
|
576
|
+
state.stage = next_stage
|
|
577
|
+
state.attempt = 1
|
|
578
|
+
state.last_failure = None
|
|
579
|
+
state.awaiting_approval = False
|
|
580
|
+
state.clarification_required = False
|
|
581
|
+
save_state(state)
|
|
582
|
+
else:
|
|
583
|
+
state.stage = Stage.COMPLETE
|
|
584
|
+
save_state(state)
|
|
585
|
+
|
|
586
|
+
# Workflow complete
|
|
587
|
+
if state.stage == Stage.COMPLETE:
|
|
588
|
+
app.show_workflow_complete()
|
|
589
|
+
app.update_stage("COMPLETE")
|
|
590
|
+
app.set_status("complete", "workflow finished")
|
|
591
|
+
|
|
592
|
+
completion_event = threading.Event()
|
|
593
|
+
completion_result = {"value": None}
|
|
594
|
+
|
|
595
|
+
def handle_completion(choice):
|
|
596
|
+
completion_result["value"] = choice
|
|
597
|
+
completion_event.set()
|
|
598
|
+
|
|
599
|
+
app.show_prompt(PromptType.COMPLETION, "Workflow complete!", handle_completion)
|
|
600
|
+
completion_event.wait()
|
|
601
|
+
|
|
602
|
+
if completion_result["value"] == "yes":
|
|
603
|
+
app._workflow_result = "create_pr"
|
|
604
|
+
elif completion_result["value"] == "no":
|
|
605
|
+
state.stage = Stage.DEV
|
|
606
|
+
state.attempt = 1
|
|
607
|
+
save_state(state)
|
|
608
|
+
app._workflow_result = "back_to_dev"
|
|
609
|
+
else:
|
|
610
|
+
app._workflow_result = "paused"
|
|
611
|
+
|
|
612
|
+
except Exception as e:
|
|
613
|
+
app.show_message(f"Error: {e}", "error")
|
|
614
|
+
app._workflow_result = "error"
|
|
615
|
+
finally:
|
|
616
|
+
workflow_done.set()
|
|
617
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
618
|
+
|
|
619
|
+
# Start workflow in background thread
|
|
620
|
+
thread = threading.Thread(target=workflow_thread, daemon=True)
|
|
621
|
+
|
|
622
|
+
def start_thread():
|
|
623
|
+
thread.start()
|
|
624
|
+
|
|
625
|
+
app.call_later(start_thread)
|
|
626
|
+
app.run()
|
|
627
|
+
|
|
628
|
+
# Handle result
|
|
629
|
+
result = app._workflow_result or "paused"
|
|
630
|
+
|
|
631
|
+
if result == "create_pr":
|
|
632
|
+
from galangal.commands.complete import finalize_task
|
|
633
|
+
finalize_task(state.task_name, state, force=False)
|
|
634
|
+
elif result == "back_to_dev":
|
|
635
|
+
# Restart workflow from DEV
|
|
636
|
+
return _run_workflow_with_tui(state)
|
|
637
|
+
elif result == "paused":
|
|
638
|
+
_handle_pause(state)
|
|
639
|
+
|
|
640
|
+
return result
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _run_workflow_legacy(state: WorkflowState) -> None:
|
|
644
|
+
"""Run workflow without persistent TUI (legacy mode)."""
|
|
645
|
+
from galangal.commands.approve import prompt_plan_approval
|
|
646
|
+
from galangal.commands.complete import finalize_task
|
|
647
|
+
|
|
648
|
+
global _current_state
|
|
649
|
+
config = get_config()
|
|
650
|
+
max_retries = config.stages.max_retries
|
|
651
|
+
|
|
652
|
+
_current_state = state
|
|
653
|
+
original_handler = signal.signal(signal.SIGINT, _signal_handler)
|
|
654
|
+
|
|
655
|
+
try:
|
|
656
|
+
while True:
|
|
657
|
+
# Run stages until COMPLETE
|
|
658
|
+
while state.stage != Stage.COMPLETE:
|
|
659
|
+
if get_pause_requested():
|
|
660
|
+
_handle_pause(state)
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
success, message = execute_stage(state)
|
|
664
|
+
|
|
665
|
+
if get_pause_requested() or message == "PAUSED: User requested pause":
|
|
666
|
+
_handle_pause(state)
|
|
667
|
+
return
|
|
668
|
+
|
|
669
|
+
if not success:
|
|
670
|
+
# Handle preflight failures specially - don't auto-retry
|
|
671
|
+
if message.startswith("PREFLIGHT_FAILED:"):
|
|
672
|
+
detailed_error = message[len("PREFLIGHT_FAILED:") :]
|
|
673
|
+
console.print("\n[red]✗ Preflight checks failed[/red]\n")
|
|
674
|
+
console.print(detailed_error)
|
|
675
|
+
console.print()
|
|
676
|
+
|
|
677
|
+
# Prompt user to retry after fixing
|
|
678
|
+
while True:
|
|
679
|
+
console.print("[yellow]Fix environment issues and retry?[/yellow]")
|
|
680
|
+
console.print(" [bold]1[/bold] Retry")
|
|
681
|
+
console.print(" [bold]2[/bold] Quit")
|
|
682
|
+
choice = console.input("\n[bold]Choice:[/bold] ").strip()
|
|
683
|
+
if choice == "1":
|
|
684
|
+
console.print("\n[dim]Retrying preflight checks...[/dim]")
|
|
685
|
+
break # Break out of while loop, continue outer loop
|
|
686
|
+
elif choice == "2":
|
|
687
|
+
save_state(state)
|
|
688
|
+
return
|
|
689
|
+
else:
|
|
690
|
+
console.print("[red]Invalid choice. Please enter 1 or 2.[/red]\n")
|
|
691
|
+
continue # Retry without incrementing attempt
|
|
692
|
+
|
|
693
|
+
if state.awaiting_approval or state.clarification_required:
|
|
694
|
+
console.print(f"\n{message}")
|
|
695
|
+
save_state(state)
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
if handle_rollback(state, message):
|
|
699
|
+
continue
|
|
700
|
+
|
|
701
|
+
state.attempt += 1
|
|
702
|
+
state.last_failure = message
|
|
703
|
+
|
|
704
|
+
if state.attempt > max_retries:
|
|
705
|
+
console.print(
|
|
706
|
+
f"\n[red]Max retries ({max_retries}) exceeded for stage {state.stage.value}[/red]"
|
|
707
|
+
)
|
|
708
|
+
console.print("\n[bold]Last failure:[/bold]")
|
|
709
|
+
console.print(message[:2000])
|
|
710
|
+
console.print()
|
|
711
|
+
|
|
712
|
+
# Prompt user for what to do
|
|
713
|
+
while True:
|
|
714
|
+
console.print(f"[yellow]Stage {state.stage.value} failed after {max_retries} attempts. What would you like to do?[/yellow]")
|
|
715
|
+
console.print(" [bold]1[/bold] Retry (reset attempts)")
|
|
716
|
+
console.print(" [bold]2[/bold] Fix in DEV (add feedback and roll back)")
|
|
717
|
+
console.print(" [bold]3[/bold] Quit")
|
|
718
|
+
choice = console.input("\n[bold]Choice:[/bold] ").strip()
|
|
719
|
+
|
|
720
|
+
if choice == "1":
|
|
721
|
+
state.attempt = 1 # Reset attempts
|
|
722
|
+
console.print("\n[dim]Retrying stage...[/dim]")
|
|
723
|
+
save_state(state)
|
|
724
|
+
break
|
|
725
|
+
elif choice == "2":
|
|
726
|
+
feedback = console.input("\n[bold]Describe what needs to be fixed:[/bold] ").strip()
|
|
727
|
+
if not feedback:
|
|
728
|
+
feedback = "Fix the failing stage"
|
|
729
|
+
failing_stage = state.stage.value
|
|
730
|
+
state.stage = Stage.DEV
|
|
731
|
+
state.attempt = 1
|
|
732
|
+
state.last_failure = f"Feedback from {failing_stage} failure: {feedback}\n\nOriginal error:\n{message[:1500]}"
|
|
733
|
+
console.print("\n[yellow]Rolling back to DEV with feedback[/yellow]")
|
|
734
|
+
save_state(state)
|
|
735
|
+
break
|
|
736
|
+
elif choice == "3":
|
|
737
|
+
save_state(state)
|
|
738
|
+
return
|
|
739
|
+
else:
|
|
740
|
+
console.print("[red]Invalid choice. Please enter 1, 2, or 3.[/red]\n")
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
console.print(
|
|
744
|
+
f"\n[yellow]Stage failed, retrying (attempt {state.attempt}/{max_retries})...[/yellow]"
|
|
745
|
+
)
|
|
746
|
+
console.print(f"Failure: {message[:500]}...")
|
|
747
|
+
save_state(state)
|
|
748
|
+
continue
|
|
749
|
+
|
|
750
|
+
console.print(
|
|
751
|
+
f"\n[green]Stage {state.stage.value} completed successfully[/green]"
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# Plan approval gate
|
|
755
|
+
if state.stage == Stage.PM and not artifact_exists("APPROVAL.md", state.task_name):
|
|
756
|
+
result = prompt_plan_approval(state.task_name, state)
|
|
757
|
+
if result == "quit":
|
|
758
|
+
_handle_pause(state)
|
|
759
|
+
return
|
|
760
|
+
elif result == "rejected":
|
|
761
|
+
continue
|
|
762
|
+
|
|
763
|
+
if state.stage == Stage.DEV:
|
|
764
|
+
archive_rollback_if_exists(state.task_name)
|
|
765
|
+
|
|
766
|
+
next_stage = get_next_stage(state.stage, state)
|
|
767
|
+
if next_stage:
|
|
768
|
+
expected_next_idx = STAGE_ORDER.index(state.stage) + 1
|
|
769
|
+
actual_next_idx = STAGE_ORDER.index(next_stage)
|
|
770
|
+
if actual_next_idx > expected_next_idx:
|
|
771
|
+
skipped = STAGE_ORDER[expected_next_idx:actual_next_idx]
|
|
772
|
+
for s in skipped:
|
|
773
|
+
console.print(
|
|
774
|
+
f" [dim]⏭️ Skipped {s.value} (condition not met)[/dim]"
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
state.stage = next_stage
|
|
778
|
+
state.attempt = 1
|
|
779
|
+
state.last_failure = None
|
|
780
|
+
state.awaiting_approval = False
|
|
781
|
+
state.clarification_required = False
|
|
782
|
+
save_state(state)
|
|
783
|
+
else:
|
|
784
|
+
state.stage = Stage.COMPLETE
|
|
785
|
+
save_state(state)
|
|
786
|
+
|
|
787
|
+
# Workflow complete
|
|
788
|
+
console.print("\n" + "=" * 60)
|
|
789
|
+
console.print("[bold green]WORKFLOW COMPLETE[/bold green]")
|
|
790
|
+
console.print("=" * 60)
|
|
791
|
+
|
|
792
|
+
from rich.prompt import Prompt
|
|
793
|
+
|
|
794
|
+
console.print("\n[bold]Options:[/bold]")
|
|
795
|
+
console.print(" [green]y[/green] - Create PR and finalize")
|
|
796
|
+
console.print(" [yellow]n[/yellow] - Review and make changes (go back to DEV)")
|
|
797
|
+
console.print(" [yellow]q[/yellow] - Quit (finalize later with 'galangal complete')")
|
|
798
|
+
|
|
799
|
+
while True:
|
|
800
|
+
choice = Prompt.ask("Your choice", default="y").strip().lower()
|
|
801
|
+
|
|
802
|
+
if choice in ["y", "yes"]:
|
|
803
|
+
finalize_task(state.task_name, state, force=False)
|
|
804
|
+
return
|
|
805
|
+
elif choice in ["n", "no"]:
|
|
806
|
+
state.stage = Stage.DEV
|
|
807
|
+
state.attempt = 1
|
|
808
|
+
save_state(state)
|
|
809
|
+
console.print("\n[dim]Going back to DEV stage...[/dim]")
|
|
810
|
+
break
|
|
811
|
+
elif choice in ["q", "quit"]:
|
|
812
|
+
_handle_pause(state)
|
|
813
|
+
return
|
|
814
|
+
else:
|
|
815
|
+
console.print("[red]Invalid choice. Enter y/n/q[/red]")
|
|
816
|
+
|
|
817
|
+
finally:
|
|
818
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
819
|
+
_current_state = None
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _handle_pause(state: WorkflowState) -> None:
|
|
823
|
+
"""Handle a pause request."""
|
|
824
|
+
set_pause_requested(False)
|
|
825
|
+
save_state(state)
|
|
826
|
+
|
|
827
|
+
console.print("\n" + "=" * 60)
|
|
828
|
+
console.print("[yellow]⏸️ TASK PAUSED[/yellow]")
|
|
829
|
+
console.print("=" * 60)
|
|
830
|
+
console.print(f"\nTask: {state.task_name}")
|
|
831
|
+
console.print(f"Stage: {state.stage.value} (attempt {state.attempt})")
|
|
832
|
+
console.print("\nYour progress has been saved. You can safely shut down now.")
|
|
833
|
+
console.print("\nTo resume later, run:")
|
|
834
|
+
console.print(" [cyan]galangal resume[/cyan]")
|
|
835
|
+
console.print("=" * 60)
|