tapps-agents 3.5.39__py3-none-any.whl → 3.5.41__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.
Files changed (71) hide show
  1. tapps_agents/__init__.py +2 -2
  2. tapps_agents/agents/enhancer/agent.py +2728 -2728
  3. tapps_agents/agents/implementer/agent.py +35 -13
  4. tapps_agents/agents/reviewer/agent.py +43 -10
  5. tapps_agents/agents/reviewer/scoring.py +59 -68
  6. tapps_agents/agents/reviewer/tools/__init__.py +24 -0
  7. tapps_agents/agents/reviewer/tools/ruff_grouping.py +250 -0
  8. tapps_agents/agents/reviewer/tools/scoped_mypy.py +284 -0
  9. tapps_agents/beads/__init__.py +11 -0
  10. tapps_agents/beads/hydration.py +213 -0
  11. tapps_agents/beads/specs.py +206 -0
  12. tapps_agents/cli/commands/health.py +19 -3
  13. tapps_agents/cli/commands/simple_mode.py +842 -676
  14. tapps_agents/cli/commands/task.py +227 -0
  15. tapps_agents/cli/commands/top_level.py +13 -0
  16. tapps_agents/cli/main.py +658 -651
  17. tapps_agents/cli/parsers/top_level.py +1978 -1881
  18. tapps_agents/core/config.py +1622 -1622
  19. tapps_agents/core/init_project.py +3012 -2897
  20. tapps_agents/epic/markdown_sync.py +105 -0
  21. tapps_agents/epic/orchestrator.py +1 -2
  22. tapps_agents/epic/parser.py +427 -423
  23. tapps_agents/experts/adaptive_domain_detector.py +0 -2
  24. tapps_agents/experts/knowledge/api-design-integration/api-security-patterns.md +15 -15
  25. tapps_agents/experts/knowledge/api-design-integration/external-api-integration.md +19 -44
  26. tapps_agents/health/checks/outcomes.backup_20260204_064058.py +324 -0
  27. tapps_agents/health/checks/outcomes.backup_20260204_064256.py +324 -0
  28. tapps_agents/health/checks/outcomes.backup_20260204_064600.py +324 -0
  29. tapps_agents/health/checks/outcomes.py +134 -46
  30. tapps_agents/health/orchestrator.py +12 -4
  31. tapps_agents/hooks/__init__.py +33 -0
  32. tapps_agents/hooks/config.py +140 -0
  33. tapps_agents/hooks/events.py +135 -0
  34. tapps_agents/hooks/executor.py +128 -0
  35. tapps_agents/hooks/manager.py +143 -0
  36. tapps_agents/session/__init__.py +19 -0
  37. tapps_agents/session/manager.py +256 -0
  38. tapps_agents/simple_mode/code_snippet_handler.py +382 -0
  39. tapps_agents/simple_mode/intent_parser.py +29 -4
  40. tapps_agents/simple_mode/orchestrators/base.py +185 -59
  41. tapps_agents/simple_mode/orchestrators/build_orchestrator.py +2667 -2642
  42. tapps_agents/simple_mode/orchestrators/fix_orchestrator.py +2 -2
  43. tapps_agents/simple_mode/workflow_suggester.py +37 -3
  44. tapps_agents/workflow/agent_handlers/implementer_handler.py +18 -3
  45. tapps_agents/workflow/cursor_executor.py +2337 -2118
  46. tapps_agents/workflow/direct_execution_fallback.py +16 -3
  47. tapps_agents/workflow/message_formatter.py +2 -1
  48. tapps_agents/workflow/models.py +38 -1
  49. tapps_agents/workflow/parallel_executor.py +43 -4
  50. tapps_agents/workflow/parser.py +375 -357
  51. tapps_agents/workflow/rules_generator.py +337 -337
  52. tapps_agents/workflow/skill_invoker.py +9 -3
  53. {tapps_agents-3.5.39.dist-info → tapps_agents-3.5.41.dist-info}/METADATA +5 -1
  54. {tapps_agents-3.5.39.dist-info → tapps_agents-3.5.41.dist-info}/RECORD +58 -54
  55. tapps_agents/agents/analyst/SKILL.md +0 -85
  56. tapps_agents/agents/architect/SKILL.md +0 -80
  57. tapps_agents/agents/debugger/SKILL.md +0 -66
  58. tapps_agents/agents/designer/SKILL.md +0 -78
  59. tapps_agents/agents/documenter/SKILL.md +0 -95
  60. tapps_agents/agents/enhancer/SKILL.md +0 -189
  61. tapps_agents/agents/implementer/SKILL.md +0 -117
  62. tapps_agents/agents/improver/SKILL.md +0 -55
  63. tapps_agents/agents/ops/SKILL.md +0 -64
  64. tapps_agents/agents/orchestrator/SKILL.md +0 -238
  65. tapps_agents/agents/planner/story_template.md +0 -37
  66. tapps_agents/agents/reviewer/templates/quality-dashboard.html.j2 +0 -150
  67. tapps_agents/agents/tester/SKILL.md +0 -71
  68. {tapps_agents-3.5.39.dist-info → tapps_agents-3.5.41.dist-info}/WHEEL +0 -0
  69. {tapps_agents-3.5.39.dist-info → tapps_agents-3.5.41.dist-info}/entry_points.txt +0 -0
  70. {tapps_agents-3.5.39.dist-info → tapps_agents-3.5.41.dist-info}/licenses/LICENSE +0 -0
  71. {tapps_agents-3.5.39.dist-info → tapps_agents-3.5.41.dist-info}/top_level.txt +0 -0
@@ -1,2118 +1,2337 @@
1
- """
2
- Cursor-Native Workflow Executor.
3
-
4
- This module provides a Cursor-native execution model that uses Cursor Skills
5
- and direct execution for LLM operations.
6
- """
7
-
8
- # @ai-prime-directive: This file implements the Cursor-native workflow executor for Cursor Skills integration.
9
- # This executor is used when running in Cursor mode (TAPPS_AGENTS_MODE=cursor) and invokes Cursor Skills
10
- # for LLM operations instead of direct API calls. Do not modify the Skill invocation pattern without
11
- # updating Cursor Skills integration and tests.
12
-
13
- # @ai-constraints:
14
- # - Must only execute in Cursor mode (is_cursor_mode() must return True)
15
- # - Must use SkillInvoker for all LLM operations - do not make direct API calls
16
- # - Workflow state must be compatible with WorkflowExecutor for cross-mode compatibility
17
- # - Performance: Skill invocation should complete in <5s for typical operations
18
- # - Must maintain backward compatibility with WorkflowExecutor workflow definitions
19
-
20
- # @note[2025-01-15]: Cursor-first runtime policy per ADR-002.
21
- # The framework operates in "tools-only" mode under Cursor, leveraging Cursor's LLM capabilities.
22
- # See docs/architecture/decisions/ADR-002-cursor-first-runtime.md
23
-
24
- from __future__ import annotations
25
-
26
- import asyncio
27
- import hashlib
28
- import os
29
- from collections.abc import AsyncIterator
30
- from contextlib import asynccontextmanager
31
- from dataclasses import asdict
32
- from datetime import datetime
33
- from pathlib import Path
34
- from typing import Any
35
-
36
- from ..core.project_profile import (
37
- ProjectProfile,
38
- ProjectProfileDetector,
39
- load_project_profile,
40
- save_project_profile,
41
- )
42
- from ..core.runtime_mode import is_cursor_mode
43
- from .auto_progression import AutoProgressionManager, ProgressionAction
44
- from .checkpoint_manager import (
45
- CheckpointConfig,
46
- CheckpointFrequency,
47
- WorkflowCheckpointManager,
48
- )
49
- from .error_recovery import ErrorContext, ErrorRecoveryManager
50
- from .event_bus import FileBasedEventBus
51
- from .events import EventType, WorkflowEvent
52
- from .logging_helper import WorkflowLogger
53
- from .marker_writer import MarkerWriter
54
- from .models import Artifact, StepExecution, Workflow, WorkflowState, WorkflowStep
55
- from .parallel_executor import ParallelStepExecutor
56
- from .progress_manager import ProgressUpdateManager
57
- from .skill_invoker import SkillInvoker
58
- from .state_manager import AdvancedStateManager
59
- from .state_persistence_config import StatePersistenceConfigManager
60
- from .worktree_manager import WorktreeManager
61
-
62
-
63
- class CursorWorkflowExecutor:
64
- """
65
- Cursor-native workflow executor that uses Skills.
66
-
67
- This executor is used when running in Cursor mode (TAPPS_AGENTS_MODE=cursor).
68
- It invokes Cursor Skills for LLM operations.
69
- """
70
-
71
- def __init__(
72
- self,
73
- project_root: Path | None = None,
74
- expert_registry: Any | None = None,
75
- auto_mode: bool = False,
76
- ):
77
- """
78
- Initialize Cursor-native workflow executor.
79
-
80
- Args:
81
- project_root: Root directory for the project
82
- expert_registry: Optional ExpertRegistry instance for expert consultation
83
- auto_mode: Whether to run in fully automated mode (no prompts)
84
- """
85
- if not is_cursor_mode():
86
- raise RuntimeError(
87
- "CursorWorkflowExecutor can only be used in Cursor mode. "
88
- "Use WorkflowExecutor for headless mode."
89
- )
90
-
91
- self.project_root = project_root or Path.cwd()
92
- self.state: WorkflowState | None = None
93
- self.workflow: Workflow | None = None
94
- self.expert_registry = expert_registry
95
- self.auto_mode = auto_mode
96
- self.skill_invoker = SkillInvoker(
97
- project_root=self.project_root, use_api=True
98
- )
99
- self.worktree_manager = WorktreeManager(project_root=self.project_root)
100
- self.project_profile: ProjectProfile | None = None
101
- self.parallel_executor = ParallelStepExecutor(max_parallel=8, default_timeout_seconds=3600.0)
102
- self.logger: WorkflowLogger | None = None # Initialized in start() with workflow_id
103
- self.progress_manager: ProgressUpdateManager | None = None # Initialized in start() with workflow
104
-
105
- # Issue fix: Support for continue-from and skip-steps flags
106
- self.continue_from: str | None = None
107
- self.skip_steps: list[str] = []
108
- self.print_paths: bool = True # Issue fix: Print artifact paths after each step
109
-
110
- # Initialize event bus for event-driven communication (Phase 2)
111
- self.event_bus = FileBasedEventBus(project_root=self.project_root)
112
-
113
- # Initialize auto-progression manager (Epic 10)
114
- auto_progression_enabled = os.getenv("TAPPS_AGENTS_AUTO_PROGRESSION", "true").lower() == "true"
115
- self.auto_progression = AutoProgressionManager(
116
- auto_progression_enabled=auto_progression_enabled,
117
- auto_retry_enabled=True,
118
- max_retries=3,
119
- )
120
-
121
- # Initialize error recovery manager (Epic 14)
122
- error_recovery_enabled = os.getenv("TAPPS_AGENTS_ERROR_RECOVERY", "true").lower() == "true"
123
- self.error_recovery = ErrorRecoveryManager(
124
- enable_auto_retry=error_recovery_enabled,
125
- max_retries=3,
126
- ) if error_recovery_enabled else None
127
-
128
- # Initialize state persistence configuration manager (Epic 12 - Story 12.6)
129
- self.state_config_manager = StatePersistenceConfigManager(project_root=self.project_root)
130
-
131
- # Initialize checkpoint manager (Epic 12)
132
- # Use configuration from state persistence config if available
133
- state_config = self.state_config_manager.config
134
- if state_config and state_config.checkpoint:
135
- checkpoint_frequency = state_config.checkpoint.mode
136
- checkpoint_interval = state_config.checkpoint.interval
137
- checkpoint_enabled = state_config.checkpoint.enabled
138
- else:
139
- # Fall back to environment variables
140
- checkpoint_frequency = os.getenv("TAPPS_AGENTS_CHECKPOINT_FREQUENCY", "every_step")
141
- checkpoint_interval = int(os.getenv("TAPPS_AGENTS_CHECKPOINT_INTERVAL", "1"))
142
- checkpoint_enabled = os.getenv("TAPPS_AGENTS_CHECKPOINT_ENABLED", "true").lower() == "true"
143
-
144
- try:
145
- frequency = CheckpointFrequency(checkpoint_frequency)
146
- except ValueError:
147
- frequency = CheckpointFrequency.EVERY_STEP
148
-
149
- checkpoint_config = CheckpointConfig(
150
- frequency=frequency,
151
- interval=checkpoint_interval,
152
- enabled=checkpoint_enabled,
153
- )
154
- self.checkpoint_manager = WorkflowCheckpointManager(config=checkpoint_config)
155
-
156
- # Initialize state manager
157
- # Use storage location from config
158
- if state_config and state_config.enabled:
159
- state_dir = self.state_config_manager.get_storage_path()
160
- compression = state_config.compression
161
- else:
162
- state_dir = self._state_dir()
163
- compression = False
164
- self.state_manager = AdvancedStateManager(state_dir, compression=compression)
165
-
166
- # Always use direct execution via Skills (Background Agents removed)
167
-
168
- # Initialize marker writer for durable step completion tracking
169
- self.marker_writer = MarkerWriter(project_root=self.project_root)
170
-
171
- def _state_dir(self) -> Path:
172
- """Get state directory path."""
173
- return self.project_root / ".tapps-agents" / "workflow-state"
174
-
175
- def _print_step_artifacts(
176
- self,
177
- step: Any,
178
- artifacts: dict[str, Any],
179
- step_execution: Any,
180
- ) -> None:
181
- """
182
- Print artifact paths after step completion (Issue fix: Hidden workflow state).
183
-
184
- Provides clear visibility into where workflow outputs are saved.
185
- """
186
- from ..core.unicode_safe import safe_print
187
-
188
- duration = step_execution.duration_seconds if step_execution else 0
189
- duration_str = f"{duration:.1f}s" if duration else "N/A"
190
-
191
- safe_print(f"\n[OK] Step '{step.id}' completed ({duration_str})")
192
-
193
- if artifacts:
194
- print(" 📄 Artifacts created:")
195
- for art_name, art_data in artifacts.items():
196
- if isinstance(art_data, dict):
197
- path = art_data.get("path", "")
198
- if path:
199
- print(f" - {path}")
200
- else:
201
- print(f" - {art_name} (in-memory)")
202
- else:
203
- print(f" - {art_name}")
204
-
205
- # Also print workflow state location for reference
206
- if self.state:
207
- state_dir = self._state_dir()
208
- print(f" 📁 State: {state_dir / self.state.workflow_id}")
209
-
210
- def _profile_project(self) -> None:
211
- """
212
- Perform project profiling before workflow execution.
213
-
214
- Loads existing profile if available, otherwise detects and saves a new one.
215
- The profile is stored in workflow state and passed to all Skills via context.
216
- """
217
- # Try to load existing profile first
218
- self.project_profile = load_project_profile(project_root=self.project_root)
219
-
220
- # If no profile exists, detect and save it
221
- if not self.project_profile:
222
- detector = ProjectProfileDetector(project_root=self.project_root)
223
- self.project_profile = detector.detect_profile()
224
- save_project_profile(profile=self.project_profile, project_root=self.project_root)
225
-
226
- async def start(
227
- self,
228
- workflow: Workflow,
229
- user_prompt: str | None = None,
230
- ) -> WorkflowState:
231
- """
232
- Start a new workflow execution.
233
-
234
- Also executes state cleanup if configured for "on_startup" schedule.
235
-
236
- Args:
237
- workflow: Workflow to execute
238
- user_prompt: Optional user prompt for the workflow
239
-
240
- Returns:
241
- Initial workflow state
242
- """
243
- # Execute cleanup on startup if configured (Epic 12 - Story 12.6)
244
- if self.state_config_manager.config and self.state_config_manager.config.cleanup:
245
- if self.state_config_manager.config.cleanup.cleanup_schedule == "on_startup":
246
- cleanup_result = self.state_config_manager.execute_cleanup()
247
- if self.logger:
248
- self.logger.info(
249
- f"State cleanup on startup: {cleanup_result}",
250
- cleanup_result=cleanup_result,
251
- )
252
-
253
- self.workflow = workflow
254
-
255
- # Check workflow metadata for auto-execution override (per-workflow config)
256
- # Always use direct execution via Skills (Background Agents removed)
257
-
258
- # Use consistent workflow_id format: {workflow.id}-{timestamp}
259
- workflow_id = f"{workflow.id}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
260
-
261
- # Initialize logger with workflow_id for correlation
262
- self.logger = WorkflowLogger(workflow_id=workflow_id)
263
-
264
- # Perform project profiling before workflow execution
265
- self._profile_project()
266
-
267
- self.state = WorkflowState(
268
- workflow_id=workflow_id,
269
- started_at=datetime.now(),
270
- current_step=workflow.steps[0].id if workflow.steps else None,
271
- status="running",
272
- variables={
273
- "user_prompt": user_prompt or "",
274
- "project_profile": self.project_profile.to_dict() if self.project_profile else None,
275
- "workflow_name": workflow.name, # Store in variables for reference
276
- },
277
- )
278
-
279
- # Beads: create workflow issue when enabled (store for close in run finally)
280
- try:
281
- from ..core.config import load_config
282
- from ..beads import require_beads
283
- from ..simple_mode.beads_hooks import create_workflow_issue
284
-
285
- config = load_config(self.project_root / ".tapps-agents" / "config.yaml")
286
- require_beads(config, self.project_root)
287
- state_vars = self.state.variables or {}
288
- # On resume: reuse id from .beads_issue_id file (same layout as *build)
289
- state_dir = self._state_dir()
290
- wf_dir = state_dir / workflow_id
291
- beads_file = wf_dir / ".beads_issue_id"
292
- if beads_file.exists():
293
- try:
294
- bid = beads_file.read_text(encoding="utf-8").strip() or None
295
- if bid:
296
- state_vars["_beads_issue_id"] = bid
297
- self.state.variables = state_vars
298
- except OSError:
299
- pass
300
- if "_beads_issue_id" not in state_vars:
301
- bid = create_workflow_issue(
302
- self.project_root,
303
- config,
304
- workflow.name,
305
- user_prompt or state_vars.get("target_file", "") or "",
306
- )
307
- if bid:
308
- state_vars["_beads_issue_id"] = bid
309
- self.state.variables = state_vars
310
- try:
311
- wf_dir.mkdir(parents=True, exist_ok=True)
312
- beads_file.write_text(bid, encoding="utf-8")
313
- except OSError:
314
- pass
315
- except Exception as e:
316
- from ..beads import BeadsRequiredError
317
-
318
- if isinstance(e, BeadsRequiredError):
319
- raise
320
- pass # log-and-continue: do not fail start for other beads errors
321
-
322
- # Generate and save execution plan (Epic 6 - Story 6.7)
323
- try:
324
- from .execution_plan import generate_execution_plan, save_execution_plan
325
- execution_plan = generate_execution_plan(workflow)
326
- state_dir = self._state_dir()
327
- plan_path = save_execution_plan(execution_plan, state_dir, workflow_id)
328
- if self.logger:
329
- self.logger.info(
330
- f"Execution plan generated: {plan_path}",
331
- execution_plan_path=str(plan_path),
332
- )
333
- except Exception as e:
334
- # Don't fail workflow start if execution plan generation fails
335
- if self.logger:
336
- self.logger.warning(f"Failed to generate execution plan: {e}")
337
-
338
- self.logger.info(
339
- "Workflow started",
340
- workflow_name=workflow.name,
341
- workflow_version=workflow.version,
342
- step_count=len(workflow.steps),
343
- )
344
-
345
- # Publish workflow started event (Phase 2)
346
- await self.event_bus.publish(
347
- WorkflowEvent(
348
- event_type=EventType.WORKFLOW_STARTED,
349
- workflow_id=workflow_id,
350
- step_id=None,
351
- data={
352
- "workflow_name": workflow.name,
353
- "workflow_version": workflow.version,
354
- "step_count": len(workflow.steps),
355
- "user_prompt": user_prompt or "",
356
- },
357
- timestamp=datetime.now(),
358
- correlation_id=workflow_id,
359
- )
360
- )
361
-
362
- # Initialize progress update manager
363
- self.progress_manager = ProgressUpdateManager(
364
- workflow=workflow,
365
- state=self.state,
366
- project_root=self.project_root,
367
- enable_updates=True,
368
- )
369
- # Connect event bus to status monitor (Phase 2)
370
- if self.progress_manager.status_monitor:
371
- self.progress_manager.status_monitor.event_bus = self.event_bus
372
- # Start progress monitoring (non-blocking)
373
- import asyncio
374
- try:
375
- asyncio.get_running_loop()
376
- asyncio.create_task(self.progress_manager.start())
377
- except RuntimeError:
378
- # No running event loop - progress manager will start when event loop is available
379
- pass
380
-
381
- self.save_state()
382
-
383
- # Generate task manifest (Epic 7)
384
- self._generate_manifest()
385
-
386
- return self.state
387
-
388
- def save_state(self) -> None:
389
- """Save workflow state to disk."""
390
- if not self.state:
391
- return
392
-
393
- def _make_json_serializable(obj: Any) -> Any:
394
- """Recursively convert objects to JSON-serializable format."""
395
- # Handle ProjectProfile objects
396
- if hasattr(obj, "to_dict") and hasattr(obj, "compliance_requirements"):
397
- try:
398
- from ..core.project_profile import ProjectProfile
399
- if isinstance(obj, ProjectProfile):
400
- return obj.to_dict()
401
- except (ImportError, AttributeError):
402
- pass
403
-
404
- # Handle ComplianceRequirement objects
405
- if hasattr(obj, "name") and hasattr(obj, "confidence") and hasattr(obj, "indicators"):
406
- try:
407
- from ..core.project_profile import ComplianceRequirement
408
- if isinstance(obj, ComplianceRequirement):
409
- return asdict(obj)
410
- except (ImportError, AttributeError):
411
- pass
412
-
413
- # Handle dictionaries recursively
414
- if isinstance(obj, dict):
415
- return {k: _make_json_serializable(v) for k, v in obj.items()}
416
-
417
- # Handle lists recursively
418
- if isinstance(obj, list):
419
- return [_make_json_serializable(item) for item in obj]
420
-
421
- # Handle other non-serializable types
422
- try:
423
- import json
424
- json.dumps(obj)
425
- return obj
426
- except (TypeError, ValueError):
427
- # For non-serializable types, convert to string as fallback
428
- return str(obj)
429
-
430
- state_file = self._state_dir() / f"{self.state.workflow_id}.json"
431
- state_file.parent.mkdir(parents=True, exist_ok=True)
432
-
433
- # Convert variables to JSON-serializable format
434
- variables = self.state.variables or {}
435
- serializable_variables = _make_json_serializable(variables)
436
-
437
- # Convert to dict for JSON serialization
438
- state_dict = {
439
- "workflow_id": self.state.workflow_id,
440
- "status": self.state.status,
441
- "current_step": self.state.current_step,
442
- "started_at": self.state.started_at.isoformat() if self.state.started_at else None,
443
- "completed_steps": self.state.completed_steps,
444
- "skipped_steps": self.state.skipped_steps,
445
- "variables": serializable_variables,
446
- "artifacts": {
447
- name: {
448
- "name": a.name,
449
- "path": a.path,
450
- "status": a.status,
451
- "created_by": a.created_by,
452
- "created_at": a.created_at.isoformat() if a.created_at else None,
453
- "metadata": a.metadata,
454
- }
455
- for name, a in self.state.artifacts.items()
456
- },
457
- "step_executions": [
458
- {
459
- "step_id": se.step_id,
460
- "agent": se.agent,
461
- "action": se.action,
462
- "started_at": se.started_at.isoformat() if se.started_at else None,
463
- "completed_at": se.completed_at.isoformat() if se.completed_at else None,
464
- "duration_seconds": se.duration_seconds,
465
- "status": se.status,
466
- "error": se.error,
467
- }
468
- for se in self.state.step_executions
469
- ],
470
- "error": self.state.error,
471
- }
472
-
473
- from .file_utils import atomic_write_json
474
-
475
- atomic_write_json(state_file, state_dict, indent=2)
476
-
477
- # Also save to history
478
- history_dir = state_file.parent / "history"
479
- history_dir.mkdir(exist_ok=True)
480
- history_file = history_dir / state_file.name
481
- atomic_write_json(history_file, state_dict, indent=2)
482
-
483
- # Generate task manifest (Epic 7)
484
- self._generate_manifest()
485
-
486
- def _generate_manifest(self) -> None:
487
- """
488
- Generate and save task manifest (Epic 7).
489
-
490
- Generates manifest on workflow start, step completion, and state save.
491
- """
492
- if not self.workflow or not self.state:
493
- return
494
-
495
- try:
496
- from .manifest import (
497
- generate_manifest,
498
- save_manifest,
499
- sync_manifest_to_project_root,
500
- )
501
-
502
- # Generate manifest
503
- manifest_content = generate_manifest(self.workflow, self.state)
504
-
505
- # Save to state directory
506
- state_dir = self._state_dir()
507
- manifest_path = save_manifest(manifest_content, state_dir, self.state.workflow_id)
508
-
509
- # Optional: Sync to project root if configured
510
- sync_enabled = os.getenv("TAPPS_AGENTS_MANIFEST_SYNC", "false").lower() == "true"
511
- if sync_enabled:
512
- sync_path = sync_manifest_to_project_root(manifest_content, self.project_root)
513
- if self.logger:
514
- self.logger.debug(
515
- "Task manifest synced to project root",
516
- manifest_path=str(manifest_path),
517
- sync_path=str(sync_path),
518
- )
519
- elif self.logger:
520
- self.logger.debug(
521
- "Task manifest generated",
522
- manifest_path=str(manifest_path),
523
- )
524
- except Exception as e:
525
- # Don't fail workflow if manifest generation fails
526
- if self.logger:
527
- self.logger.warning(
528
- "Failed to generate task manifest",
529
- error=str(e),
530
- )
531
-
532
- async def run(
533
- self,
534
- workflow: Workflow | None = None,
535
- target_file: str | None = None,
536
- max_steps: int = 100,
537
- ) -> WorkflowState:
538
- """
539
- Run workflow to completion with timeout protection.
540
-
541
- Args:
542
- workflow: Workflow to execute (if not already loaded)
543
- target_file: Optional target file path
544
- max_steps: Maximum number of steps to execute
545
-
546
- Returns:
547
- Final workflow state
548
- """
549
- import asyncio
550
- from datetime import datetime
551
-
552
- from tapps_agents.core.config import load_config
553
-
554
- config = load_config()
555
- # Use 2x step timeout for overall workflow timeout (default: 2 hours)
556
- workflow_timeout = getattr(config.workflow, 'timeout_seconds', 3600.0) * 2
557
-
558
- async def _run_workflow_inner() -> WorkflowState:
559
- """Inner function to wrap actual execution for timeout protection."""
560
- # Initialize execution
561
- target_path = await self._initialize_run(workflow, target_file)
562
-
563
- # Log workflow start
564
- start_time = datetime.now()
565
- if self.logger:
566
- self.logger.info(
567
- "Starting workflow execution",
568
- extra={
569
- "workflow_id": self.state.workflow_id if self.state else None,
570
- "workflow_name": workflow.name if workflow else (self.workflow.name if self.workflow else None),
571
- "max_steps": max_steps,
572
- "total_steps": len(workflow.steps) if workflow else (len(self.workflow.steps) if self.workflow else 0),
573
- "workflow_timeout": workflow_timeout,
574
- }
575
- )
576
-
577
- # Use parallel execution for independent steps
578
- steps_executed = 0
579
- completed_step_ids = set(self.state.completed_steps)
580
- running_step_ids: set[str] = set()
581
-
582
- while (
583
- self.state
584
- and self.workflow
585
- and self.state.status == "running"
586
- ):
587
- if steps_executed >= max_steps:
588
- self._handle_max_steps_exceeded(max_steps)
589
- break
590
-
591
- # Find steps ready to execute (dependencies met)
592
- ready_steps = self._find_ready_steps(
593
- completed_step_ids, running_step_ids
594
- )
595
-
596
- if not ready_steps:
597
- if self._handle_no_ready_steps(completed_step_ids):
598
- break
599
- continue
600
-
601
- # Execute ready steps in parallel
602
- running_step_ids.update(step.id for step in ready_steps)
603
-
604
- async def execute_step_wrapper(step: WorkflowStep) -> dict[str, Any]:
605
- """Wrapper to adapt _execute_step_for_parallel to parallel executor interface."""
606
- artifacts = await self._execute_step_for_parallel(step=step, target_path=target_path)
607
- return artifacts or {}
608
-
609
- try:
610
- results = await self.parallel_executor.execute_parallel(
611
- steps=ready_steps,
612
- execute_fn=execute_step_wrapper,
613
- state=self.state,
614
- )
615
-
616
- # Process results and update state
617
- should_break = await self._process_parallel_results(
618
- results, completed_step_ids, running_step_ids
619
- )
620
- if should_break:
621
- break
622
-
623
- steps_executed += len(ready_steps)
624
- self.save_state()
625
-
626
- # Generate task manifest after step completion (Epic 7)
627
- self._generate_manifest()
628
-
629
- # Log progress every 10 steps
630
- if steps_executed % 10 == 0 and self.logger:
631
- elapsed = (datetime.now() - start_time).total_seconds()
632
- self.logger.info(
633
- f"Workflow progress: {steps_executed} steps executed in {elapsed:.1f}s",
634
- extra={
635
- "steps_executed": steps_executed,
636
- "completed_steps": len(completed_step_ids),
637
- "total_steps": len(self.workflow.steps),
638
- "elapsed_seconds": elapsed,
639
- }
640
- )
641
-
642
- except Exception as e:
643
- self._handle_execution_error(e)
644
- break
645
-
646
- return await self._finalize_run(completed_step_ids)
647
-
648
- # Wrap execution with timeout
649
- try:
650
- return await asyncio.wait_for(
651
- _run_workflow_inner(),
652
- timeout=workflow_timeout
653
- )
654
- except TimeoutError:
655
- if self.state:
656
- self.state.status = "failed"
657
- self.state.error = f"Workflow timeout after {workflow_timeout}s"
658
- self.save_state()
659
- if self.logger:
660
- self.logger.error(
661
- f"Workflow execution exceeded {workflow_timeout}s timeout",
662
- extra={
663
- "workflow_id": self.state.workflow_id,
664
- "timeout_seconds": workflow_timeout,
665
- }
666
- )
667
- raise TimeoutError(
668
- f"Workflow execution exceeded {workflow_timeout}s timeout. "
669
- f"Increase timeout in config (workflow.timeout_seconds) or check for blocking operations."
670
- ) from None
671
- finally:
672
- variables = (getattr(self.state, "variables", None) or {}) if self.state else {}
673
- beads_issue_id = variables.get("_beads_issue_id")
674
- if beads_issue_id is None and self.state:
675
- wf_id = getattr(self.state, "workflow_id", None)
676
- if wf_id:
677
- beads_file = self._state_dir() / wf_id / ".beads_issue_id"
678
- if beads_file.exists():
679
- try:
680
- beads_issue_id = beads_file.read_text(
681
- encoding="utf-8"
682
- ).strip() or None
683
- except OSError:
684
- pass
685
- from ..simple_mode.beads_hooks import close_issue
686
- close_issue(self.project_root, beads_issue_id)
687
-
688
- async def _initialize_run(
689
- self,
690
- workflow: Workflow | None,
691
- target_file: str | None,
692
- ) -> Path | None:
693
- """Initialize workflow execution with validation and return target path."""
694
- if workflow:
695
- self.workflow = workflow
696
- if not self.workflow:
697
- raise ValueError(
698
- "No workflow loaded. Call start() or pass workflow."
699
- )
700
-
701
- # Validate workflow has steps
702
- if not self.workflow.steps:
703
- raise ValueError("Workflow has no steps to execute")
704
-
705
- # Ensure we have a state
706
- if not self.state or not self.state.workflow_id.startswith(f"{self.workflow.id}-"):
707
- await self.start(workflow=self.workflow)
708
-
709
- # Validate first step can be executed (no dependencies)
710
- first_step = self.workflow.steps[0]
711
- if not first_step.requires: # No dependencies
712
- # First step should always be ready
713
- if self.logger:
714
- self.logger.info(
715
- f"First step {first_step.id} has no dependencies - ready to execute",
716
- extra={
717
- "step_id": first_step.id,
718
- "agent": first_step.agent,
719
- "action": first_step.action,
720
- }
721
- )
722
-
723
- # Establish target file
724
- target_path: Path | None = None
725
- if target_file:
726
- target_path = (
727
- (self.project_root / target_file)
728
- if not Path(target_file).is_absolute()
729
- else Path(target_file)
730
- )
731
- else:
732
- target_path = self._default_target_file()
733
-
734
- if target_path and self.state:
735
- self.state.variables["target_file"] = str(target_path)
736
-
737
- return target_path
738
-
739
- def _handle_max_steps_exceeded(self, max_steps: int) -> None:
740
- """Handle max steps exceeded."""
741
- self.state.status = "failed"
742
- self.state.error = f"Max steps exceeded ({max_steps}). Aborting."
743
- self.save_state()
744
-
745
- def get_workflow_health(self) -> dict[str, Any]:
746
- """
747
- Get workflow health diagnostics.
748
-
749
- Returns:
750
- Dictionary with workflow health information including:
751
- - status: Current workflow status
752
- - elapsed_seconds: Time since workflow started
753
- - completed_steps: Number of completed steps
754
- - total_steps: Total number of steps
755
- - progress_percent: Percentage of steps completed
756
- - time_since_last_step: Seconds since last step completed
757
- - is_stuck: Whether workflow appears to be stuck (no progress in 5 minutes)
758
- - current_step: Current step ID
759
- - error: Error message if any
760
- """
761
- if not self.state:
762
- return {"status": "not_started", "message": "Workflow not started"}
763
-
764
- elapsed = (
765
- (datetime.now() - self.state.started_at).total_seconds()
766
- if self.state.started_at else 0
767
- )
768
- completed = len(self.state.completed_steps)
769
- total = len(self.workflow.steps) if self.workflow else 0
770
-
771
- # Check if stuck (no progress in last 5 minutes)
772
- last_step_time = None
773
- if self.state.step_executions:
774
- completed_times = [
775
- se.completed_at for se in self.state.step_executions
776
- if se.completed_at
777
- ]
778
- if completed_times:
779
- last_step_time = max(completed_times)
780
-
781
- if not last_step_time:
782
- last_step_time = self.state.started_at
783
-
784
- time_since_last_step = (
785
- (datetime.now() - last_step_time).total_seconds()
786
- if last_step_time else elapsed
787
- )
788
- is_stuck = time_since_last_step > 300 # 5 minutes
789
-
790
- return {
791
- "status": self.state.status,
792
- "elapsed_seconds": elapsed,
793
- "completed_steps": completed,
794
- "total_steps": total,
795
- "progress_percent": (completed / total * 100) if total > 0 else 0,
796
- "time_since_last_step": time_since_last_step,
797
- "is_stuck": is_stuck,
798
- "current_step": self.state.current_step,
799
- "error": self.state.error,
800
- }
801
-
802
- def _find_ready_steps(
803
- self,
804
- completed_step_ids: set[str],
805
- running_step_ids: set[str],
806
- ) -> list[WorkflowStep]:
807
- """Find steps ready to execute (dependencies met)."""
808
- available_artifacts = set(self.state.artifacts.keys())
809
- return self.parallel_executor.find_ready_steps(
810
- workflow_steps=self.workflow.steps,
811
- completed_step_ids=completed_step_ids,
812
- running_step_ids=running_step_ids,
813
- available_artifacts=available_artifacts,
814
- )
815
-
816
- def _handle_no_ready_steps(self, completed_step_ids: set[str]) -> bool:
817
- """Handle case when no steps are ready with better diagnostics. Returns True if workflow should stop."""
818
- if len(completed_step_ids) >= len(self.workflow.steps):
819
- # Workflow is complete
820
- self.state.status = "completed"
821
- self.state.current_step = None
822
- self.save_state()
823
- return True
824
- else:
825
- # Workflow is blocked - provide diagnostics
826
- available_artifacts = set(self.state.artifacts.keys())
827
- pending_steps = [
828
- s for s in self.workflow.steps
829
- if s.id not in completed_step_ids
830
- ]
831
-
832
- # Check what's blocking
833
- blocking_info = []
834
- for step in pending_steps:
835
- missing = [req for req in (step.requires or []) if req not in available_artifacts]
836
- if missing:
837
- blocking_info.append(f"Step {step.id} ({step.agent}/{step.action}): missing {missing}")
838
-
839
- error_msg = (
840
- f"Workflow blocked: no ready steps and workflow not complete. "
841
- f"Completed: {len(completed_step_ids)}/{len(self.workflow.steps)}. "
842
- f"Blocking issues: {blocking_info if blocking_info else 'Unknown - check step dependencies'}"
843
- )
844
-
845
- self.state.status = "failed"
846
- self.state.error = error_msg
847
- self.save_state()
848
-
849
- # Log detailed diagnostics
850
- if self.logger:
851
- self.logger.error(
852
- "Workflow blocked - no ready steps",
853
- extra={
854
- "completed_steps": list(completed_step_ids),
855
- "pending_steps": [s.id for s in pending_steps],
856
- "available_artifacts": list(available_artifacts),
857
- "blocking_info": blocking_info,
858
- }
859
- )
860
-
861
- return True
862
-
863
- async def _process_parallel_results(
864
- self,
865
- results: list[Any],
866
- completed_step_ids: set[str],
867
- running_step_ids: set[str],
868
- ) -> bool:
869
- """
870
- Process results from parallel execution.
871
- Returns True if workflow should stop (failed or aborted).
872
- """
873
- for result in results:
874
- step_logger = self.logger.with_context(
875
- step_id=result.step.id,
876
- agent=result.step.agent,
877
- ) if self.logger else None
878
-
879
- if result.error:
880
- should_break = await self._handle_step_error(
881
- result, step_logger, completed_step_ids, running_step_ids
882
- )
883
- if should_break:
884
- return True
885
- continue
886
-
887
- # Handle successful step completion
888
- await self._handle_step_success(
889
- result, step_logger, completed_step_ids, running_step_ids
890
- )
891
-
892
- return False
893
-
894
- async def _handle_step_error(
895
- self,
896
- result: Any,
897
- step_logger: Any,
898
- completed_step_ids: set[str],
899
- running_step_ids: set[str],
900
- ) -> bool:
901
- """Handle step error. Returns True if workflow should stop."""
902
- # Publish step failed event (Phase 2)
903
- await self.event_bus.publish(
904
- WorkflowEvent(
905
- event_type=EventType.STEP_FAILED,
906
- workflow_id=self.state.workflow_id,
907
- step_id=result.step.id,
908
- data={
909
- "agent": result.step.agent,
910
- "action": result.step.action,
911
- "error": str(result.error),
912
- "attempts": getattr(result, "attempts", 1),
913
- },
914
- timestamp=datetime.now(),
915
- correlation_id=f"{self.state.workflow_id}:{result.step.id}",
916
- )
917
- )
918
-
919
- # Step failed - use error recovery and auto-progression (Epic 14)
920
- error_context = ErrorContext(
921
- workflow_id=self.state.workflow_id,
922
- step_id=result.step.id,
923
- agent=result.step.agent,
924
- action=result.step.action,
925
- step_number=None,
926
- total_steps=len(self.workflow.steps),
927
- workflow_status=self.state.status,
928
- )
929
-
930
- # Handle error with recovery manager (Epic 14)
931
- recovery_result = None
932
- user_friendly_error = None
933
- if self.error_recovery:
934
- recovery_result = self.error_recovery.handle_error(
935
- error=result.error,
936
- context=error_context,
937
- attempt=getattr(result, "attempts", 1),
938
- )
939
-
940
- # Store user-friendly message (can't modify frozen dataclass)
941
- if recovery_result.get("user_message"):
942
- user_friendly_error = recovery_result["user_message"]
943
-
944
- if self.auto_progression.should_auto_progress():
945
- # Get review result if this was a reviewer step
946
- review_result = None
947
- if result.step.agent == "reviewer":
948
- review_result = self.state.variables.get("reviewer_result")
949
-
950
- decision = self.auto_progression.handle_step_completion(
951
- step=result.step,
952
- state=self.state,
953
- step_execution=result.step_execution,
954
- review_result=review_result,
955
- )
956
-
957
- if decision.action == ProgressionAction.RETRY:
958
- # Retry the step - remove from completed and add back to ready
959
- completed_step_ids.discard(result.step.id)
960
- running_step_ids.discard(result.step.id)
961
- # Apply backoff if specified
962
- if decision.metadata.get("backoff_seconds"):
963
- await asyncio.sleep(decision.metadata["backoff_seconds"])
964
- if step_logger:
965
- step_logger.info(
966
- f"Retrying step {result.step.id} (attempt {decision.retry_count})",
967
- )
968
- return False
969
- elif decision.action == ProgressionAction.SKIP:
970
- # Skip this step
971
- completed_step_ids.add(result.step.id)
972
- running_step_ids.discard(result.step.id)
973
- if result.step.id not in self.state.skipped_steps:
974
- self.state.skipped_steps.append(result.step.id)
975
- if step_logger:
976
- step_logger.warning(
977
- f"Skipping step {result.step.id}: {decision.reason}",
978
- )
979
- return False
980
- elif decision.action == ProgressionAction.ABORT:
981
- # Abort workflow
982
- self.state.status = "failed"
983
- self.state.error = decision.reason
984
- if step_logger:
985
- step_logger.error(
986
- f"Workflow aborted: {decision.reason}",
987
- )
988
-
989
- # Publish workflow failed event (Phase 2)
990
- await self.event_bus.publish(
991
- WorkflowEvent(
992
- event_type=EventType.WORKFLOW_FAILED,
993
- workflow_id=self.state.workflow_id,
994
- step_id=result.step.id,
995
- data={
996
- "error": decision.reason,
997
- "step_id": result.step.id,
998
- },
999
- timestamp=datetime.now(),
1000
- correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1001
- )
1002
- )
1003
-
1004
- self.save_state()
1005
- if self.progress_manager:
1006
- await self.progress_manager.send_workflow_failed(decision.reason)
1007
- await self.progress_manager.stop()
1008
- return True
1009
- elif decision.action == ProgressionAction.CONTINUE:
1010
- # Continue despite error (recoverable)
1011
- completed_step_ids.add(result.step.id)
1012
- running_step_ids.discard(result.step.id)
1013
- if step_logger:
1014
- step_logger.warning(
1015
- f"Step {result.step.id} failed but continuing: {decision.reason}",
1016
- )
1017
- return False
1018
-
1019
- # Fallback: WorkflowFailureConfig when auto-progression disabled (plan 3.1)
1020
- error_message = user_friendly_error if user_friendly_error else str(result.error)
1021
- try:
1022
- from ..core.config import load_config
1023
-
1024
- cfg = load_config()
1025
- wf = getattr(cfg, "workflow", None)
1026
- fail_cfg = getattr(wf, "failure", None) if wf else None
1027
- except Exception: # pylint: disable=broad-except
1028
- fail_cfg = None
1029
- on_fail = getattr(fail_cfg, "on_step_fail", "fail") or "fail"
1030
- retry_count = getattr(fail_cfg, "retry_count", 1) or 0
1031
- escalate_pause = getattr(fail_cfg, "escalate_to_pause", True)
1032
-
1033
- raw = self.state.variables.get("_step_retries")
1034
- retries_var = raw if isinstance(raw, dict) else {}
1035
- self.state.variables["_step_retries"] = retries_var
1036
- retries_used = retries_var.get(result.step.id, 0)
1037
-
1038
- if on_fail == "retry" and retries_used < retry_count:
1039
- retries_var[result.step.id] = retries_used + 1
1040
- completed_step_ids.discard(result.step.id)
1041
- running_step_ids.discard(result.step.id)
1042
- if step_logger:
1043
- step_logger.info(f"Retrying step {result.step.id} (attempt {retries_used + 1}/{retry_count})")
1044
- return False
1045
-
1046
- if on_fail == "skip":
1047
- completed_step_ids.add(result.step.id)
1048
- running_step_ids.discard(result.step.id)
1049
- if result.step.id not in self.state.skipped_steps:
1050
- self.state.skipped_steps.append(result.step.id)
1051
- if step_logger:
1052
- step_logger.warning(f"Skipping step {result.step.id}: {error_message}")
1053
- return False
1054
-
1055
- # fail or escalate: stop workflow
1056
- self.state.status = "paused" if (on_fail == "escalate" and escalate_pause) else "failed"
1057
- self.state.error = f"Step {result.step.id} failed: {error_message}"
1058
- suggest = None
1059
- if on_fail == "escalate" and recovery_result and recovery_result.get("suggestions"):
1060
- suggest = [getattr(s, "action", str(s)) for s in recovery_result["suggestions"][:3]]
1061
-
1062
- # Publish workflow failed event (Phase 2)
1063
- await self.event_bus.publish(
1064
- WorkflowEvent(
1065
- event_type=EventType.WORKFLOW_FAILED,
1066
- workflow_id=self.state.workflow_id,
1067
- step_id=result.step.id,
1068
- data={
1069
- "error": error_message,
1070
- "step_id": result.step.id,
1071
- "behavior": on_fail,
1072
- "suggestions": suggest,
1073
- },
1074
- timestamp=datetime.now(),
1075
- correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1076
- )
1077
- )
1078
-
1079
- self.save_state()
1080
-
1081
- # Send failure update
1082
- if self.progress_manager:
1083
- await self.progress_manager.send_workflow_failed(error_message)
1084
- await self.progress_manager.stop()
1085
- return True
1086
-
1087
- async def _handle_step_success(
1088
- self,
1089
- result: Any,
1090
- step_logger: Any,
1091
- completed_step_ids: set[str],
1092
- running_step_ids: set[str],
1093
- ) -> None:
1094
- """Handle successful step completion."""
1095
- # Mark step as completed
1096
- completed_step_ids.add(result.step.id)
1097
- running_step_ids.discard(result.step.id)
1098
-
1099
- # Get review result if this was a reviewer step (for gate evaluation)
1100
- review_result = None
1101
- if result.step.agent == "reviewer":
1102
- review_result = self.state.variables.get("reviewer_result")
1103
-
1104
- # Issue fix: Print artifact paths after each step (Hidden workflow state)
1105
- if self.print_paths and result.artifacts:
1106
- self._print_step_artifacts(result.step, result.artifacts, result.step_execution)
1107
-
1108
- # Publish step completed event (Phase 2)
1109
- await self.event_bus.publish(
1110
- WorkflowEvent(
1111
- event_type=EventType.STEP_COMPLETED,
1112
- workflow_id=self.state.workflow_id,
1113
- step_id=result.step.id,
1114
- data={
1115
- "agent": result.step.agent,
1116
- "action": result.step.action,
1117
- "duration_seconds": result.step_execution.duration_seconds,
1118
- "artifact_count": len(result.artifacts) if result.artifacts else 0,
1119
- },
1120
- timestamp=datetime.now(),
1121
- correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1122
- )
1123
- )
1124
-
1125
- # Publish artifact created events (Phase 2)
1126
- if result.artifacts:
1127
- for artifact_name, artifact_data in result.artifacts.items():
1128
- await self.event_bus.publish(
1129
- WorkflowEvent(
1130
- event_type=EventType.ARTIFACT_CREATED,
1131
- workflow_id=self.state.workflow_id,
1132
- step_id=result.step.id,
1133
- data={
1134
- "artifact_name": artifact_name,
1135
- "artifact_path": artifact_data.get("path", ""),
1136
- "created_by": result.step.id,
1137
- },
1138
- timestamp=datetime.now(),
1139
- correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1140
- )
1141
- )
1142
-
1143
- # Use auto-progression to handle step completion and gate evaluation
1144
- if self.auto_progression.should_auto_progress():
1145
- decision = self.auto_progression.handle_step_completion(
1146
- step=result.step,
1147
- state=self.state,
1148
- step_execution=result.step_execution,
1149
- review_result=review_result,
1150
- )
1151
-
1152
- # Update current step based on gate decision if needed
1153
- if decision.next_step_id:
1154
- self.state.current_step = decision.next_step_id
1155
-
1156
- if step_logger:
1157
- step_logger.info(
1158
- f"Step completed: {decision.reason}",
1159
- action=result.step.action,
1160
- duration_seconds=result.step_execution.duration_seconds,
1161
- artifact_count=len(result.artifacts) if result.artifacts else 0,
1162
- next_step=decision.next_step_id,
1163
- )
1164
- else:
1165
- if step_logger:
1166
- step_logger.info(
1167
- "Step completed",
1168
- action=result.step.action,
1169
- duration_seconds=result.step_execution.duration_seconds,
1170
- artifact_count=len(result.artifacts) if result.artifacts else 0,
1171
- )
1172
-
1173
- # Send step completed update (Epic 11: Include gate result for quality dashboard)
1174
- is_gate_step = result.step.agent == "reviewer" and result.step.gate is not None
1175
- if self.progress_manager:
1176
- # Extract gate result if this was a reviewer step
1177
- gate_result = None
1178
- if result.step.agent == "reviewer" and review_result:
1179
- # Get gate result from state variables (set by auto-progression)
1180
- gate_last = self.state.variables.get("gate_last", {})
1181
- if gate_last:
1182
- gate_result = gate_last
1183
-
1184
- # Publish gate evaluated event (Phase 2)
1185
- await self.event_bus.publish(
1186
- WorkflowEvent(
1187
- event_type=EventType.GATE_EVALUATED,
1188
- workflow_id=self.state.workflow_id,
1189
- step_id=result.step.id,
1190
- data={
1191
- "gate_result": gate_result,
1192
- "passed": gate_result.get("passed", False),
1193
- },
1194
- timestamp=datetime.now(),
1195
- correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1196
- )
1197
- )
1198
-
1199
- await self.progress_manager.send_step_completed(
1200
- step_id=result.step.id,
1201
- agent=result.step.agent,
1202
- action=result.step.action,
1203
- duration=result.step_execution.duration_seconds,
1204
- gate_result=gate_result,
1205
- )
1206
-
1207
- # Epic 12: Automatic checkpointing after step completion
1208
- if self.checkpoint_manager.should_checkpoint(
1209
- step=result.step,
1210
- state=self.state,
1211
- is_gate_step=is_gate_step,
1212
- ):
1213
- # Enhance state with checkpoint metadata before saving
1214
- checkpoint_metadata = self.checkpoint_manager.get_checkpoint_metadata(
1215
- state=self.state,
1216
- step=result.step,
1217
- )
1218
- # Store metadata in state variables for persistence
1219
- if "_checkpoint_metadata" not in self.state.variables:
1220
- self.state.variables["_checkpoint_metadata"] = {}
1221
- self.state.variables["_checkpoint_metadata"].update(checkpoint_metadata)
1222
-
1223
- # Save checkpoint
1224
- self.save_state()
1225
- self.checkpoint_manager.record_checkpoint(result.step.id)
1226
-
1227
- if self.logger:
1228
- self.logger.info(
1229
- f"Checkpoint created after step {result.step.id}",
1230
- checkpoint_metadata=checkpoint_metadata,
1231
- )
1232
-
1233
- # Update artifacts from result
1234
- if result.artifacts and isinstance(result.artifacts, dict):
1235
- for art_name, art_data in result.artifacts.items():
1236
- if isinstance(art_data, dict):
1237
- artifact = Artifact(
1238
- name=art_data.get("name", art_name),
1239
- path=art_data.get("path", ""),
1240
- status="complete",
1241
- created_by=result.step.id,
1242
- created_at=datetime.now(),
1243
- metadata=art_data.get("metadata", {}),
1244
- )
1245
- self.state.artifacts[artifact.name] = artifact
1246
-
1247
- def _handle_execution_error(self, error: Exception) -> None:
1248
- """Handle execution error."""
1249
- self.state.status = "failed"
1250
- self.state.error = str(error)
1251
- if self.logger:
1252
- self.logger.error(
1253
- "Workflow execution failed",
1254
- error=str(error),
1255
- exc_info=True,
1256
- )
1257
- self.save_state()
1258
-
1259
- async def _finalize_run(self, completed_step_ids: set[str]) -> WorkflowState:
1260
- """Finalize workflow execution and return final state."""
1261
- if not self.state:
1262
- raise RuntimeError("Workflow state lost during execution")
1263
-
1264
- # Mark as completed if no error
1265
- if self.state.status == "running":
1266
- self.state.status = "completed"
1267
- if self.logger:
1268
- self.logger.info(
1269
- "Workflow completed",
1270
- completed_steps=len(completed_step_ids),
1271
- total_steps=len(self.workflow.steps) if self.workflow else 0,
1272
- )
1273
-
1274
- # Publish workflow completed event (Phase 2)
1275
- await self.event_bus.publish(
1276
- WorkflowEvent(
1277
- event_type=EventType.WORKFLOW_COMPLETED,
1278
- workflow_id=self.state.workflow_id,
1279
- step_id=None,
1280
- data={
1281
- "completed_steps": len(completed_step_ids),
1282
- "total_steps": len(self.workflow.steps) if self.workflow else 0,
1283
- },
1284
- timestamp=datetime.now(),
1285
- correlation_id=self.state.workflow_id,
1286
- )
1287
- )
1288
-
1289
- self.save_state()
1290
-
1291
- # Send completion summary
1292
- if self.progress_manager:
1293
- await self.progress_manager.send_workflow_completed()
1294
- await self.progress_manager.stop()
1295
-
1296
- # Best-effort cleanup of worktrees created during this run
1297
- try:
1298
- await self.worktree_manager.cleanup_all()
1299
- except Exception:
1300
- pass
1301
-
1302
- # Dual-write workflow completion to analytics (best-effort)
1303
- if self.state.status in ("completed", "failed") and self.workflow:
1304
- try:
1305
- from .analytics_dual_write import record_workflow_execution_to_analytics
1306
-
1307
- duration_sec = 0.0
1308
- if self.state.started_at:
1309
- end = datetime.now()
1310
- duration_sec = (end - self.state.started_at).total_seconds()
1311
- record_workflow_execution_to_analytics(
1312
- project_root=self.project_root,
1313
- workflow_id=self.state.workflow_id,
1314
- workflow_name=self.workflow.name or self.state.workflow_id,
1315
- duration_seconds=duration_sec,
1316
- steps=len(self.workflow.steps),
1317
- success=(self.state.status == "completed"),
1318
- )
1319
- except Exception: # pylint: disable=broad-except
1320
- pass
1321
-
1322
- return self.state
1323
-
1324
- async def _execute_step_for_parallel(
1325
- self, step: WorkflowStep, target_path: Path | None
1326
- ) -> dict[str, dict[str, Any]] | None:
1327
- """
1328
- Execute a single workflow step using Cursor Skills and return artifacts (for parallel execution).
1329
-
1330
- This is similar to _execute_step but returns artifacts instead of updating state.
1331
- State updates (step_execution tracking) are handled by ParallelStepExecutor.
1332
- """
1333
- if not self.state or not self.workflow:
1334
- raise ValueError("Workflow not started")
1335
-
1336
- action = self._normalize_action(step.action)
1337
- agent_name = (step.agent or "").strip().lower()
1338
-
1339
- # Publish step started event (Phase 2)
1340
- await self.event_bus.publish(
1341
- WorkflowEvent(
1342
- event_type=EventType.STEP_STARTED,
1343
- workflow_id=self.state.workflow_id,
1344
- step_id=step.id,
1345
- data={
1346
- "agent": agent_name,
1347
- "action": action,
1348
- "step_id": step.id,
1349
- },
1350
- timestamp=datetime.now(),
1351
- correlation_id=f"{self.state.workflow_id}:{step.id}",
1352
- )
1353
- )
1354
-
1355
- # Handle completion/finalization steps that don't require agent execution
1356
- if agent_name == "orchestrator" and action in ["finalize", "complete"]:
1357
- # Return empty artifacts for completion steps
1358
- return {}
1359
-
1360
- # Track step start time for duration calculation
1361
- step_started_at = datetime.now()
1362
-
1363
- # Use context manager for worktree lifecycle (guaranteed cleanup)
1364
- async with self._worktree_context(step) as worktree_path:
1365
- worktree_name = self._worktree_name_for_step(step.id)
1366
-
1367
- # Background Agent auto-execution removed - always use skill_invoker
1368
- try:
1369
- # Invoke Skill via SkillInvoker (direct execution or Cursor Skills)
1370
- from ..core.unicode_safe import safe_print
1371
- safe_print(f"\n[EXEC] Executing {agent_name}/{action}...", flush=True)
1372
- await self.skill_invoker.invoke_skill(
1373
- agent_name=agent_name,
1374
- action=action,
1375
- step=step,
1376
- target_path=target_path,
1377
- worktree_path=worktree_path,
1378
- state=self.state,
1379
- )
1380
- # Skill invoker handles execution (direct execution or Cursor Skills)
1381
- # Artifacts are extracted after completion
1382
-
1383
- # Extract artifacts from worktree
1384
- artifacts = await self.worktree_manager.extract_artifacts(
1385
- worktree_path=worktree_path,
1386
- step=step,
1387
- )
1388
-
1389
- # Convert artifacts to dict format
1390
- artifacts_dict: dict[str, dict[str, Any]] = {}
1391
- found_artifact_paths = []
1392
- for artifact in artifacts:
1393
- artifacts_dict[artifact.name] = {
1394
- "name": artifact.name,
1395
- "path": artifact.path,
1396
- "status": artifact.status,
1397
- "created_by": artifact.created_by,
1398
- "created_at": artifact.created_at.isoformat() if artifact.created_at else None,
1399
- "metadata": artifact.metadata or {},
1400
- }
1401
- found_artifact_paths.append(artifact.path)
1402
-
1403
- # Write DONE marker for successful completion
1404
- step_completed_at = datetime.now()
1405
- duration = (step_completed_at - step_started_at).total_seconds()
1406
-
1407
- marker_path = self.marker_writer.write_done_marker(
1408
- workflow_id=self.state.workflow_id,
1409
- step_id=step.id,
1410
- agent=agent_name,
1411
- action=action,
1412
- worktree_name=worktree_name,
1413
- worktree_path=str(worktree_path),
1414
- expected_artifacts=step.creates or [],
1415
- found_artifacts=found_artifact_paths,
1416
- duration_seconds=duration,
1417
- started_at=step_started_at,
1418
- completed_at=step_completed_at,
1419
- )
1420
-
1421
- if self.logger:
1422
- self.logger.debug(
1423
- f"DONE marker written for step {step.id}",
1424
- marker_path=str(marker_path),
1425
- )
1426
-
1427
- # Worktree cleanup is handled by context manager
1428
- return artifacts_dict if artifacts_dict else None
1429
-
1430
- except (TimeoutError, RuntimeError) as e:
1431
- # Write FAILED marker for timeout or execution errors
1432
- step_failed_at = datetime.now()
1433
- duration = (step_failed_at - step_started_at).total_seconds()
1434
- error_type = type(e).__name__
1435
- error_msg = str(e)
1436
-
1437
- # Try to get completion status if available (for missing artifacts)
1438
- found_artifact_paths = []
1439
- try:
1440
- from .cursor_skill_helper import check_skill_completion
1441
- completion_status = check_skill_completion(
1442
- worktree_path=worktree_path,
1443
- expected_artifacts=step.creates or [],
1444
- )
1445
- found_artifact_paths = completion_status.get("found_artifacts", [])
1446
- except Exception:
1447
- pass
1448
-
1449
- marker_path = self.marker_writer.write_failed_marker(
1450
- workflow_id=self.state.workflow_id,
1451
- step_id=step.id,
1452
- agent=agent_name,
1453
- action=action,
1454
- error=error_msg,
1455
- worktree_name=worktree_name,
1456
- worktree_path=str(worktree_path),
1457
- expected_artifacts=step.creates or [],
1458
- found_artifacts=found_artifact_paths,
1459
- duration_seconds=duration,
1460
- started_at=step_started_at,
1461
- failed_at=step_failed_at,
1462
- error_type=error_type,
1463
- metadata={
1464
- "marker_location": f".tapps-agents/workflows/markers/{self.state.workflow_id}/step-{step.id}/FAILED.json",
1465
- },
1466
- )
1467
-
1468
- if self.logger:
1469
- self.logger.warning(
1470
- f"FAILED marker written for step {step.id}",
1471
- marker_path=str(marker_path),
1472
- error=error_msg,
1473
- )
1474
-
1475
- # Include marker location in error message for better troubleshooting
1476
- from ..core.unicode_safe import safe_print
1477
- safe_print(
1478
- f"\n[INFO] Failure marker written to: {marker_path}",
1479
- flush=True,
1480
- )
1481
-
1482
- # Re-raise the exception
1483
- raise
1484
- except Exception as e:
1485
- # Write FAILED marker for unexpected errors
1486
- step_failed_at = datetime.now()
1487
- duration = (step_failed_at - step_started_at).total_seconds()
1488
- error_type = type(e).__name__
1489
- error_msg = str(e)
1490
-
1491
- marker_path = self.marker_writer.write_failed_marker(
1492
- workflow_id=self.state.workflow_id,
1493
- step_id=step.id,
1494
- agent=agent_name,
1495
- action=action,
1496
- error=error_msg,
1497
- worktree_name=worktree_name,
1498
- worktree_path=str(worktree_path) if 'worktree_path' in locals() else None,
1499
- expected_artifacts=step.creates or [],
1500
- found_artifacts=[],
1501
- duration_seconds=duration,
1502
- started_at=step_started_at,
1503
- failed_at=step_failed_at,
1504
- error_type=error_type,
1505
- metadata={
1506
- "marker_location": f".tapps-agents/workflows/markers/{self.state.workflow_id}/step-{step.id}/FAILED.json",
1507
- },
1508
- )
1509
-
1510
- if self.logger:
1511
- self.logger.error(
1512
- f"FAILED marker written for step {step.id} (unexpected error)",
1513
- marker_path=str(marker_path),
1514
- error=error_msg,
1515
- exc_info=True,
1516
- )
1517
-
1518
- # Re-raise the exception
1519
- raise
1520
-
1521
- @asynccontextmanager
1522
- async def _worktree_context(
1523
- self, step: WorkflowStep
1524
- ) -> AsyncIterator[Path]:
1525
- """
1526
- Context manager for worktree lifecycle management.
1527
-
1528
- Ensures worktree is properly cleaned up even on cancellation or exceptions.
1529
- This is a 2025 best practice for resource management in async code.
1530
-
1531
- Args:
1532
- step: Workflow step that needs a worktree
1533
-
1534
- Yields:
1535
- Path to the worktree
1536
-
1537
- Example:
1538
- async with self._worktree_context(step) as worktree_path:
1539
- # Use worktree_path here
1540
- # Worktree automatically cleaned up on exit
1541
- """
1542
- worktree_name = self._worktree_name_for_step(step.id)
1543
- worktree_path: Path | None = None
1544
-
1545
- try:
1546
- # Create worktree
1547
- worktree_path = await self.worktree_manager.create_worktree(
1548
- worktree_name=worktree_name
1549
- )
1550
-
1551
- # Copy artifacts from previous steps to worktree
1552
- artifacts_list = list(self.state.artifacts.values())
1553
- await self.worktree_manager.copy_artifacts(
1554
- worktree_path=worktree_path,
1555
- artifacts=artifacts_list,
1556
- )
1557
-
1558
- # Yield worktree path
1559
- yield worktree_path
1560
-
1561
- finally:
1562
- # Always cleanup, even on cancellation or exception
1563
- if worktree_path:
1564
- try:
1565
- # Determine if we should delete the branch based on configuration
1566
- from ..core.config import load_config
1567
- config = load_config()
1568
- should_delete = (
1569
- config.workflow.branch_cleanup.delete_branches_on_cleanup
1570
- if (
1571
- config.workflow.branch_cleanup
1572
- and config.workflow.branch_cleanup.enabled
1573
- )
1574
- else True # Default to True for backward compatibility (same as parameter default)
1575
- )
1576
- await self.worktree_manager.remove_worktree(
1577
- worktree_name, delete_branch=should_delete
1578
- )
1579
- except Exception as e:
1580
- # Log but don't raise - cleanup failures shouldn't break workflow
1581
- if self.logger:
1582
- self.logger.warning(
1583
- f"Failed to cleanup worktree {worktree_name}: {e}",
1584
- step_id=step.id,
1585
- )
1586
-
1587
- def _worktree_name_for_step(self, step_id: str) -> str:
1588
- """
1589
- Deterministic, collision-resistant worktree name for a workflow step.
1590
-
1591
- Keeps names short/safe for Windows while still traceable back to workflow+step.
1592
- """
1593
- if not self.state:
1594
- raise ValueError("Workflow not started")
1595
- raw = f"workflow-{self.state.workflow_id}-step-{step_id}"
1596
- digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
1597
- base = f"{raw}-{digest}"
1598
- return WorktreeManager._sanitize_component(base, max_len=80)
1599
-
1600
- def get_current_step(self) -> WorkflowStep | None:
1601
- """Get the current workflow step."""
1602
- if not self.workflow or not self.state:
1603
- return None
1604
-
1605
- for step in self.workflow.steps:
1606
- if step.id == self.state.current_step:
1607
- return step
1608
- return None
1609
-
1610
- def _default_target_file(self) -> Path | None:
1611
- """Get default target file path."""
1612
- # Try common locations
1613
- candidates = [
1614
- self.project_root / "src" / "app.py",
1615
- self.project_root / "app.py",
1616
- self.project_root / "main.py",
1617
- ]
1618
- for candidate in candidates:
1619
- if candidate.exists():
1620
- return candidate
1621
- return None
1622
-
1623
- async def _execute_step(
1624
- self, step: WorkflowStep, target_path: Path | None
1625
- ) -> None:
1626
- """
1627
- Execute a single workflow step using Cursor Skills.
1628
-
1629
- Args:
1630
- step: Workflow step to execute
1631
- target_path: Optional target file path
1632
- """
1633
- if not self.state or not self.workflow:
1634
- raise ValueError("Workflow not started")
1635
-
1636
- action = self._normalize_action(step.action)
1637
- agent_name = (step.agent or "").strip().lower()
1638
-
1639
- # Handle completion/finalization steps that don't require agent execution
1640
- if agent_name == "orchestrator" and action in ["finalize", "complete"]:
1641
- # Mark step as completed without executing an agent
1642
- step_execution = StepExecution(
1643
- step_id=step.id,
1644
- agent=agent_name,
1645
- action=action,
1646
- started_at=datetime.now(),
1647
- completed_at=datetime.now(),
1648
- status="completed",
1649
- )
1650
- self.state.step_executions.append(step_execution)
1651
- self._advance_step()
1652
- self.save_state()
1653
- return
1654
-
1655
- # Create step execution tracking
1656
- step_execution = StepExecution(
1657
- step_id=step.id,
1658
- agent=agent_name,
1659
- action=action,
1660
- started_at=datetime.now(),
1661
- )
1662
- self.state.step_executions.append(step_execution)
1663
-
1664
- try:
1665
- # Create worktree for this step
1666
- worktree_name = self._worktree_name_for_step(step.id)
1667
- worktree_path = await self.worktree_manager.create_worktree(
1668
- worktree_name=worktree_name
1669
- )
1670
-
1671
- # Copy artifacts from previous steps to worktree
1672
- artifacts_list = list(self.state.artifacts.values())
1673
- await self.worktree_manager.copy_artifacts(
1674
- worktree_path=worktree_path,
1675
- artifacts=artifacts_list,
1676
- )
1677
-
1678
- # Invoke Skill via SkillInvoker (direct execution)
1679
- result = await self.skill_invoker.invoke_skill(
1680
- agent_name=agent_name,
1681
- action=action,
1682
- step=step,
1683
- target_path=target_path,
1684
- worktree_path=worktree_path,
1685
- state=self.state,
1686
- )
1687
-
1688
- # Wait for Skill to complete (direct execution)
1689
- # Poll for artifacts or completion marker
1690
- import asyncio
1691
-
1692
- from .cursor_skill_helper import check_skill_completion
1693
-
1694
- max_wait_time = 3600 # 1 hour max wait
1695
- poll_interval = 2 # Check every 2 seconds
1696
- elapsed = 0
1697
-
1698
- print(f"Waiting for {agent_name}/{action} to complete...")
1699
- while elapsed < max_wait_time:
1700
- completion_status = check_skill_completion(
1701
- worktree_path=worktree_path,
1702
- expected_artifacts=step.creates,
1703
- )
1704
-
1705
- if completion_status["completed"]:
1706
- from ..core.unicode_safe import safe_print
1707
- safe_print(f"[OK] {agent_name}/{action} completed - found artifacts: {completion_status['found_artifacts']}")
1708
- break
1709
-
1710
- await asyncio.sleep(poll_interval)
1711
- elapsed += poll_interval
1712
-
1713
- # Print progress every 10 seconds
1714
- if elapsed % 10 == 0:
1715
- print(f" Still waiting... ({elapsed}s elapsed)")
1716
- else:
1717
- raise TimeoutError(
1718
- f"Skill {agent_name}/{action} did not complete within {max_wait_time}s. "
1719
- f"Expected artifacts: {step.creates}, Missing: {completion_status.get('missing_artifacts', [])}"
1720
- )
1721
-
1722
- # Extract artifacts from worktree
1723
- artifacts = await self.worktree_manager.extract_artifacts(
1724
- worktree_path=worktree_path,
1725
- step=step,
1726
- )
1727
-
1728
- # Update state with artifacts
1729
- for artifact in artifacts:
1730
- self.state.artifacts[artifact.name] = artifact
1731
-
1732
- # Story-level step handling (Phase 3: Story-Level Granularity)
1733
- # Verify acceptance criteria BEFORE marking step as completed
1734
- if step.metadata and step.metadata.get("story_id"):
1735
- self._handle_story_completion(step, artifacts, step_execution)
1736
-
1737
- # Update step execution (after story verification)
1738
- step_execution.completed_at = datetime.now()
1739
- step_execution.status = "completed"
1740
- step_execution.result = result
1741
-
1742
- # Remove the worktree on success (keep on failure for debugging)
1743
- try:
1744
- # Determine if we should delete the branch based on configuration
1745
- from ..core.config import load_config
1746
- config = load_config()
1747
- should_delete = (
1748
- config.workflow.branch_cleanup.delete_branches_on_cleanup
1749
- if (
1750
- config.workflow.branch_cleanup
1751
- and config.workflow.branch_cleanup.enabled
1752
- )
1753
- else True # Default to True for backward compatibility
1754
- )
1755
- await self.worktree_manager.remove_worktree(
1756
- worktree_name, delete_branch=should_delete
1757
- )
1758
- except Exception:
1759
- pass
1760
-
1761
- # Advance to next step
1762
- self._advance_step()
1763
-
1764
- except Exception as e:
1765
- step_execution.completed_at = datetime.now()
1766
- step_execution.status = "failed"
1767
- step_execution.error = str(e)
1768
- raise
1769
-
1770
- finally:
1771
- self.save_state()
1772
-
1773
- def _normalize_action(self, action: str) -> str:
1774
- """Normalize action name."""
1775
- return action.replace("_", "-").lower()
1776
-
1777
- def _get_step_params(self, step: WorkflowStep, target_path: Path | None) -> dict[str, Any]:
1778
- """
1779
- Extract parameters for step execution.
1780
-
1781
- Args:
1782
- step: Workflow step
1783
- target_path: Optional target file path
1784
-
1785
- Returns:
1786
- Dictionary of parameters for command building
1787
- """
1788
- params: dict[str, Any] = {}
1789
-
1790
- # Add target file if provided
1791
- if target_path:
1792
- try:
1793
- # Try relative path first (most common case)
1794
- resolved_target = Path(target_path).resolve()
1795
- resolved_root = self.project_root.resolve()
1796
-
1797
- # Use is_relative_to if available (Python 3.9+)
1798
- try:
1799
- if resolved_target.is_relative_to(resolved_root):
1800
- params["target_file"] = str(resolved_target.relative_to(resolved_root))
1801
- else:
1802
- # Path is outside project root - use path normalizer
1803
- from ...core.path_normalizer import normalize_for_cli
1804
- params["target_file"] = normalize_for_cli(target_path, self.project_root)
1805
- except AttributeError:
1806
- # Python < 3.9 - use try/except
1807
- try:
1808
- params["target_file"] = str(resolved_target.relative_to(resolved_root))
1809
- except ValueError:
1810
- # Path is outside project root - use path normalizer
1811
- from ...core.path_normalizer import normalize_for_cli
1812
- params["target_file"] = normalize_for_cli(target_path, self.project_root)
1813
- except Exception as e:
1814
- # Fallback: use path normalizer for any error
1815
- from ...core.path_normalizer import normalize_for_cli
1816
- if self.logger:
1817
- self.logger.warning(f"Path conversion error: {e}. Using path normalizer.")
1818
- params["target_file"] = normalize_for_cli(target_path, self.project_root)
1819
-
1820
- # Add step metadata
1821
- if step.metadata:
1822
- params.update(step.metadata)
1823
-
1824
- # Add workflow variables
1825
- if self.state and self.state.variables:
1826
- # Include relevant variables (avoid exposing everything)
1827
- if "user_prompt" in self.state.variables:
1828
- params["user_prompt"] = self.state.variables["user_prompt"]
1829
- if "target_file" in self.state.variables:
1830
- params["target_file"] = self.state.variables["target_file"]
1831
-
1832
- return params
1833
-
1834
- def _handle_story_completion(
1835
- self, step: WorkflowStep, artifacts: list[Artifact], step_execution: StepExecution
1836
- ) -> None:
1837
- """
1838
- Handle story-level step completion (Phase 3: Story-Level Granularity).
1839
-
1840
- Verifies acceptance criteria, logs to progress.txt, and tracks story completion.
1841
-
1842
- Args:
1843
- step: Completed workflow step with story metadata
1844
- artifacts: Artifacts created by the step
1845
- step_execution: Step execution record to update if criteria fail
1846
- """
1847
- if not step.metadata:
1848
- return
1849
-
1850
- story_id = step.metadata.get("story_id")
1851
- story_title = step.metadata.get("story_title")
1852
- acceptance_criteria = step.metadata.get("acceptance_criteria", [])
1853
-
1854
- if not story_id:
1855
- return # Not a story-level step
1856
-
1857
- # Verify acceptance criteria if provided
1858
- passes = True
1859
- verification_result = None
1860
-
1861
- if acceptance_criteria:
1862
- from .acceptance_verifier import AcceptanceCriteriaVerifier
1863
-
1864
- # Convert artifacts list to dict
1865
- artifacts_dict = {art.name: art for art in artifacts}
1866
-
1867
- # Get code files from artifacts
1868
- code_files = []
1869
- for art in artifacts:
1870
- if art.path:
1871
- art_path = Path(art.path)
1872
- if art_path.exists() and art_path.suffix in [".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".go", ".rs"]:
1873
- code_files.append(art_path)
1874
-
1875
- # Verify criteria
1876
- verifier = AcceptanceCriteriaVerifier()
1877
- verification_result = verifier.verify(
1878
- criteria=acceptance_criteria,
1879
- artifacts=artifacts_dict,
1880
- code_files=code_files if code_files else None,
1881
- )
1882
- passes = verification_result.get("all_passed", True)
1883
-
1884
- # Store verification result in state variables
1885
- if "story_verifications" not in self.state.variables:
1886
- self.state.variables["story_verifications"] = {}
1887
- self.state.variables["story_verifications"][story_id] = verification_result
1888
-
1889
- # Track story completion in state.variables
1890
- if "story_completions" not in self.state.variables:
1891
- self.state.variables["story_completions"] = {}
1892
- self.state.variables["story_completions"][story_id] = passes
1893
-
1894
- # Log to progress.txt if progress logger is available
1895
- try:
1896
- from .progress_logger import ProgressLogger
1897
-
1898
- progress_file = self.project_root / ".tapps-agents" / "progress.txt"
1899
- progress_logger = ProgressLogger(progress_file)
1900
-
1901
- # Extract files changed
1902
- files_changed = [art.path for art in artifacts if art.path]
1903
-
1904
- # Extract learnings from verification result
1905
- learnings = []
1906
- if verification_result and not passes:
1907
- failed_criteria = [
1908
- r["criterion"]
1909
- for r in verification_result.get("results", [])
1910
- if not r.get("passed", False)
1911
- ]
1912
- if failed_criteria:
1913
- learnings.append(f"Acceptance criteria not met: {', '.join(failed_criteria)}")
1914
-
1915
- # Log story completion
1916
- progress_logger.log_story_completion(
1917
- story_id=story_id,
1918
- story_title=story_title or step.id,
1919
- passes=passes,
1920
- files_changed=files_changed if files_changed else None,
1921
- learnings=learnings if learnings else None,
1922
- )
1923
- except Exception:
1924
- # Don't fail workflow if progress logging fails
1925
- import logging
1926
- logger = logging.getLogger(__name__)
1927
- logger.warning("Failed to log story completion to progress.txt", exc_info=True)
1928
-
1929
- # If acceptance criteria not met, mark step as failed and raise exception
1930
- if not passes:
1931
- step_execution.status = "failed"
1932
- step_execution.error = f"Acceptance criteria not met for story {story_id}"
1933
- # Raise exception to prevent advancing to next step
1934
- raise ValueError(f"Story {story_id} failed acceptance criteria verification")
1935
-
1936
- def _advance_step(self) -> None:
1937
- """Advance to the next workflow step."""
1938
- if not self.workflow or not self.state:
1939
- return
1940
-
1941
- # Use auto-progression if enabled
1942
- if self.auto_progression.should_auto_progress():
1943
- current_step = self.get_current_step()
1944
- if current_step:
1945
- # Get progression decision
1946
- step_execution = next(
1947
- (se for se in self.state.step_executions if se.step_id == current_step.id),
1948
- None
1949
- )
1950
- if step_execution:
1951
- review_result = None
1952
- if current_step.agent == "reviewer":
1953
- review_result = self.state.variables.get("reviewer_result")
1954
-
1955
- decision = self.auto_progression.handle_step_completion(
1956
- step=current_step,
1957
- state=self.state,
1958
- step_execution=step_execution,
1959
- review_result=review_result,
1960
- )
1961
-
1962
- next_step_id = self.auto_progression.get_next_step_id(
1963
- step=current_step,
1964
- decision=decision,
1965
- workflow_steps=self.workflow.steps,
1966
- )
1967
-
1968
- if next_step_id:
1969
- self.state.current_step = next_step_id
1970
- else:
1971
- # Workflow complete
1972
- self.state.status = "completed"
1973
- self.state.completed_at = datetime.now()
1974
- self.state.current_step = None
1975
- return
1976
-
1977
- # Fallback to sequential progression
1978
- current_index = None
1979
- for i, step in enumerate(self.workflow.steps):
1980
- if step.id == self.state.current_step:
1981
- current_index = i
1982
- break
1983
-
1984
- if current_index is None:
1985
- self.state.status = "failed"
1986
- self.state.error = f"Current step {self.state.current_step} not found"
1987
- return
1988
-
1989
- # Move to next step
1990
- if current_index + 1 < len(self.workflow.steps):
1991
- self.state.current_step = self.workflow.steps[current_index + 1].id
1992
- else:
1993
- # All steps completed
1994
- self.state.status = "completed"
1995
- self.state.completed_at = datetime.now()
1996
- self.state.current_step = None
1997
-
1998
- def get_progression_status(self) -> dict[str, Any]:
1999
- """
2000
- Get current progression status and visibility information.
2001
-
2002
- Returns:
2003
- Dictionary with progression status
2004
- """
2005
- if not self.workflow or not self.state:
2006
- return {"status": "not_started"}
2007
-
2008
- return self.auto_progression.get_progression_status(
2009
- state=self.state,
2010
- workflow_steps=self.workflow.steps,
2011
- )
2012
-
2013
- def get_progression_history(self, step_id: str | None = None) -> list[dict[str, Any]]:
2014
- """
2015
- Get progression history.
2016
-
2017
- Args:
2018
- step_id: Optional step ID to filter by
2019
-
2020
- Returns:
2021
- List of progression history entries
2022
- """
2023
- history = self.auto_progression.get_progression_history(step_id=step_id)
2024
- return [
2025
- {
2026
- "step_id": h.step_id,
2027
- "timestamp": h.timestamp.isoformat(),
2028
- "action": h.action.value,
2029
- "reason": h.reason,
2030
- "gate_result": h.gate_result,
2031
- "metadata": h.metadata,
2032
- }
2033
- for h in history
2034
- ]
2035
-
2036
- def pause_workflow(self) -> None:
2037
- """
2038
- Pause workflow execution.
2039
-
2040
- Epic 10: Progression Control
2041
- """
2042
- if not self.state:
2043
- raise ValueError("Workflow not started")
2044
-
2045
- if self.state.status == "running":
2046
- self.state.status = "paused"
2047
- self.save_state()
2048
- if self.logger:
2049
- self.logger.info("Workflow paused by user")
2050
- self.auto_progression.record_progression(
2051
- step_id=self.state.current_step or "unknown",
2052
- action=ProgressionAction.PAUSE,
2053
- reason="Workflow paused by user",
2054
- )
2055
-
2056
- def resume_workflow(self) -> None:
2057
- """
2058
- Resume paused workflow execution.
2059
-
2060
- Epic 10: Progression Control
2061
- """
2062
- if not self.state:
2063
- raise ValueError("Workflow not started")
2064
-
2065
- if self.state.status == "paused":
2066
- self.state.status = "running"
2067
- self.save_state()
2068
- if self.logger:
2069
- self.logger.info("Workflow resumed by user")
2070
- self.auto_progression.record_progression(
2071
- step_id=self.state.current_step or "unknown",
2072
- action=ProgressionAction.CONTINUE,
2073
- reason="Workflow resumed by user",
2074
- )
2075
-
2076
- def skip_step(self, step_id: str | None = None) -> None:
2077
- """
2078
- Skip a workflow step.
2079
-
2080
- Args:
2081
- step_id: Step ID to skip (defaults to current step)
2082
-
2083
- Epic 10: Progression Control
2084
- """
2085
- if not self.state or not self.workflow:
2086
- raise ValueError("Workflow not started")
2087
-
2088
- step_id = step_id or self.state.current_step
2089
- if not step_id:
2090
- raise ValueError("No step to skip")
2091
-
2092
- # Find the step
2093
- step = next((s for s in self.workflow.steps if s.id == step_id), None)
2094
- if not step:
2095
- raise ValueError(f"Step {step_id} not found")
2096
-
2097
- # Record skip in progression history
2098
- self.auto_progression.record_progression(
2099
- step_id=step_id,
2100
- action=ProgressionAction.SKIP,
2101
- reason="Step skipped by user",
2102
- )
2103
-
2104
- # Advance to next step
2105
- if step.next:
2106
- self.state.current_step = step.next
2107
- self.save_state()
2108
- if self.logger:
2109
- self.logger.info(f"Step {step_id} skipped, advancing to {step.next}")
2110
- else:
2111
- # No next step - workflow complete
2112
- self.state.status = "completed"
2113
- self.state.completed_at = datetime.now()
2114
- self.state.current_step = None
2115
- self.save_state()
2116
- if self.logger:
2117
- self.logger.info(f"Step {step_id} skipped, workflow completed")
2118
-
1
+ """
2
+ Cursor-Native Workflow Executor.
3
+
4
+ This module provides a Cursor-native execution model that uses Cursor Skills
5
+ and direct execution for LLM operations.
6
+ """
7
+
8
+ # @ai-prime-directive: This file implements the Cursor-native workflow executor for Cursor Skills integration.
9
+ # This executor is used when running in Cursor mode (TAPPS_AGENTS_MODE=cursor) and invokes Cursor Skills
10
+ # for LLM operations instead of direct API calls. Do not modify the Skill invocation pattern without
11
+ # updating Cursor Skills integration and tests.
12
+
13
+ # @ai-constraints:
14
+ # - Must only execute in Cursor mode (is_cursor_mode() must return True)
15
+ # - Must use SkillInvoker for all LLM operations - do not make direct API calls
16
+ # - Workflow state must be compatible with WorkflowExecutor for cross-mode compatibility
17
+ # - Performance: Skill invocation should complete in <5s for typical operations
18
+ # - Must maintain backward compatibility with WorkflowExecutor workflow definitions
19
+
20
+ # @note[2026-02-03]: Equal platform support policy per ADR-002.
21
+ # The framework provides equal support for Claude Desktop, Cursor IDE, and Claude Code CLI.
22
+ # Uses handler-first execution (AgentHandlerRegistry) before platform-specific features.
23
+ # See docs/architecture/decisions/ADR-002-equal-platform-support.md
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import hashlib
29
+ import os
30
+ import traceback
31
+ from collections.abc import AsyncIterator
32
+ from contextlib import asynccontextmanager
33
+ from dataclasses import asdict
34
+ from datetime import datetime
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from ..core.project_profile import (
39
+ ProjectProfile,
40
+ ProjectProfileDetector,
41
+ load_project_profile,
42
+ save_project_profile,
43
+ )
44
+ from ..core.runtime_mode import is_cursor_mode
45
+ from .auto_progression import AutoProgressionManager, ProgressionAction
46
+ from .checkpoint_manager import (
47
+ CheckpointConfig,
48
+ CheckpointFrequency,
49
+ WorkflowCheckpointManager,
50
+ )
51
+ from .error_recovery import ErrorContext, ErrorRecoveryManager
52
+ from .event_bus import FileBasedEventBus
53
+ from .events import EventType, WorkflowEvent
54
+ from .logging_helper import WorkflowLogger
55
+ from .marker_writer import MarkerWriter
56
+ from .models import Artifact, StepExecution, StepResult, Workflow, WorkflowState, WorkflowStep
57
+ from .parallel_executor import ParallelStepExecutor
58
+ from .progress_manager import ProgressUpdateManager
59
+ from .skill_invoker import SkillInvoker
60
+ from .state_manager import AdvancedStateManager
61
+ from .state_persistence_config import StatePersistenceConfigManager
62
+ from .worktree_manager import WorktreeManager
63
+
64
+
65
+ class CursorWorkflowExecutor:
66
+ """
67
+ Cursor-native workflow executor that uses Skills.
68
+
69
+ This executor is used when running in Cursor mode (TAPPS_AGENTS_MODE=cursor).
70
+ It invokes Cursor Skills for LLM operations.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ project_root: Path | None = None,
76
+ expert_registry: Any | None = None,
77
+ auto_mode: bool = False,
78
+ ):
79
+ """
80
+ Initialize Cursor-native workflow executor.
81
+
82
+ Args:
83
+ project_root: Root directory for the project
84
+ expert_registry: Optional ExpertRegistry instance for expert consultation
85
+ auto_mode: Whether to run in fully automated mode (no prompts)
86
+ """
87
+ if not is_cursor_mode():
88
+ raise RuntimeError(
89
+ "CursorWorkflowExecutor can only be used in Cursor mode. "
90
+ "Use WorkflowExecutor for headless mode."
91
+ )
92
+
93
+ self.project_root = project_root or Path.cwd()
94
+ self.state: WorkflowState | None = None
95
+ self.workflow: Workflow | None = None
96
+ self.expert_registry = expert_registry
97
+ self.auto_mode = auto_mode
98
+ self.skill_invoker = SkillInvoker(
99
+ project_root=self.project_root, use_api=True
100
+ )
101
+ self.worktree_manager = WorktreeManager(project_root=self.project_root)
102
+ self.project_profile: ProjectProfile | None = None
103
+ self.parallel_executor = ParallelStepExecutor(max_parallel=8, default_timeout_seconds=3600.0)
104
+ self.logger: WorkflowLogger | None = None # Initialized in start() with workflow_id
105
+ self.progress_manager: ProgressUpdateManager | None = None # Initialized in start() with workflow
106
+
107
+ # Issue fix: Support for continue-from and skip-steps flags
108
+ self.continue_from: str | None = None
109
+ self.skip_steps: list[str] = []
110
+ self.print_paths: bool = True # Issue fix: Print artifact paths after each step
111
+
112
+ # Initialize event bus for event-driven communication (Phase 2)
113
+ self.event_bus = FileBasedEventBus(project_root=self.project_root)
114
+
115
+ # Initialize auto-progression manager (Epic 10)
116
+ auto_progression_enabled = os.getenv("TAPPS_AGENTS_AUTO_PROGRESSION", "true").lower() == "true"
117
+ self.auto_progression = AutoProgressionManager(
118
+ auto_progression_enabled=auto_progression_enabled,
119
+ auto_retry_enabled=True,
120
+ max_retries=3,
121
+ )
122
+
123
+ # Initialize error recovery manager (Epic 14)
124
+ error_recovery_enabled = os.getenv("TAPPS_AGENTS_ERROR_RECOVERY", "true").lower() == "true"
125
+ self.error_recovery = ErrorRecoveryManager(
126
+ enable_auto_retry=error_recovery_enabled,
127
+ max_retries=3,
128
+ ) if error_recovery_enabled else None
129
+
130
+ # Initialize state persistence configuration manager (Epic 12 - Story 12.6)
131
+ self.state_config_manager = StatePersistenceConfigManager(project_root=self.project_root)
132
+
133
+ # Initialize checkpoint manager (Epic 12)
134
+ # Use configuration from state persistence config if available
135
+ state_config = self.state_config_manager.config
136
+ if state_config and state_config.checkpoint:
137
+ checkpoint_frequency = state_config.checkpoint.mode
138
+ checkpoint_interval = state_config.checkpoint.interval
139
+ checkpoint_enabled = state_config.checkpoint.enabled
140
+ else:
141
+ # Fall back to environment variables
142
+ checkpoint_frequency = os.getenv("TAPPS_AGENTS_CHECKPOINT_FREQUENCY", "every_step")
143
+ checkpoint_interval = int(os.getenv("TAPPS_AGENTS_CHECKPOINT_INTERVAL", "1"))
144
+ checkpoint_enabled = os.getenv("TAPPS_AGENTS_CHECKPOINT_ENABLED", "true").lower() == "true"
145
+
146
+ try:
147
+ frequency = CheckpointFrequency(checkpoint_frequency)
148
+ except ValueError:
149
+ frequency = CheckpointFrequency.EVERY_STEP
150
+
151
+ checkpoint_config = CheckpointConfig(
152
+ frequency=frequency,
153
+ interval=checkpoint_interval,
154
+ enabled=checkpoint_enabled,
155
+ )
156
+ self.checkpoint_manager = WorkflowCheckpointManager(config=checkpoint_config)
157
+
158
+ # Initialize state manager
159
+ # Use storage location from config
160
+ if state_config and state_config.enabled:
161
+ state_dir = self.state_config_manager.get_storage_path()
162
+ compression = state_config.compression
163
+ else:
164
+ state_dir = self._state_dir()
165
+ compression = False
166
+ self.state_manager = AdvancedStateManager(state_dir, compression=compression)
167
+
168
+ # Always use direct execution via Skills (Background Agents removed)
169
+
170
+ # Initialize marker writer for durable step completion tracking
171
+ self.marker_writer = MarkerWriter(project_root=self.project_root)
172
+
173
+ def _state_dir(self) -> Path:
174
+ """Get state directory path."""
175
+ return self.project_root / ".tapps-agents" / "workflow-state"
176
+
177
+ def _print_step_artifacts(
178
+ self,
179
+ step: Any,
180
+ artifacts: dict[str, Any],
181
+ step_execution: Any,
182
+ ) -> None:
183
+ """
184
+ Print artifact paths after step completion (Issue fix: Hidden workflow state).
185
+
186
+ Provides clear visibility into where workflow outputs are saved.
187
+ """
188
+ from ..core.unicode_safe import safe_print
189
+
190
+ duration = step_execution.duration_seconds if step_execution else 0
191
+ duration_str = f"{duration:.1f}s" if duration else "N/A"
192
+
193
+ safe_print(f"\n[OK] Step '{step.id}' completed ({duration_str})")
194
+
195
+ if artifacts:
196
+ print(" 📄 Artifacts created:")
197
+ for art_name, art_data in artifacts.items():
198
+ if isinstance(art_data, dict):
199
+ path = art_data.get("path", "")
200
+ if path:
201
+ print(f" - {path}")
202
+ else:
203
+ print(f" - {art_name} (in-memory)")
204
+ else:
205
+ print(f" - {art_name}")
206
+
207
+ # Also print workflow state location for reference
208
+ if self.state:
209
+ state_dir = self._state_dir()
210
+ print(f" 📁 State: {state_dir / self.state.workflow_id}")
211
+
212
+ def _profile_project(self) -> None:
213
+ """
214
+ Perform project profiling before workflow execution.
215
+
216
+ Loads existing profile if available, otherwise detects and saves a new one.
217
+ The profile is stored in workflow state and passed to all Skills via context.
218
+ """
219
+ # Try to load existing profile first
220
+ self.project_profile = load_project_profile(project_root=self.project_root)
221
+
222
+ # If no profile exists, detect and save it
223
+ if not self.project_profile:
224
+ detector = ProjectProfileDetector(project_root=self.project_root)
225
+ self.project_profile = detector.detect_profile()
226
+ save_project_profile(profile=self.project_profile, project_root=self.project_root)
227
+
228
+ async def start(
229
+ self,
230
+ workflow: Workflow,
231
+ user_prompt: str | None = None,
232
+ ) -> WorkflowState:
233
+ """
234
+ Start a new workflow execution.
235
+
236
+ Also executes state cleanup if configured for "on_startup" schedule.
237
+
238
+ Args:
239
+ workflow: Workflow to execute
240
+ user_prompt: Optional user prompt for the workflow
241
+
242
+ Returns:
243
+ Initial workflow state
244
+ """
245
+ # Execute cleanup on startup if configured (Epic 12 - Story 12.6)
246
+ if self.state_config_manager.config and self.state_config_manager.config.cleanup:
247
+ if self.state_config_manager.config.cleanup.cleanup_schedule == "on_startup":
248
+ cleanup_result = self.state_config_manager.execute_cleanup()
249
+ if self.logger:
250
+ self.logger.info(
251
+ f"State cleanup on startup: {cleanup_result}",
252
+ cleanup_result=cleanup_result,
253
+ )
254
+
255
+ self.workflow = workflow
256
+
257
+ # Check workflow metadata for auto-execution override (per-workflow config)
258
+ # Always use direct execution via Skills (Background Agents removed)
259
+
260
+ # Use consistent workflow_id format: {workflow.id}-{timestamp}
261
+ # Include microseconds to ensure uniqueness for parallel workflows (BUG-001 fix)
262
+ workflow_id = f"{workflow.id}-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}"
263
+
264
+ # Initialize logger with workflow_id for correlation
265
+ self.logger = WorkflowLogger(workflow_id=workflow_id)
266
+
267
+ # Perform project profiling before workflow execution
268
+ self._profile_project()
269
+
270
+ self.state = WorkflowState(
271
+ workflow_id=workflow_id,
272
+ started_at=datetime.now(),
273
+ current_step=workflow.steps[0].id if workflow.steps else None,
274
+ status="running",
275
+ variables={
276
+ "user_prompt": user_prompt or "",
277
+ "project_profile": self.project_profile.to_dict() if self.project_profile else None,
278
+ "workflow_name": workflow.name, # Store in variables for reference
279
+ },
280
+ )
281
+
282
+ # Beads: create workflow issue when enabled (store for close in run finally)
283
+ try:
284
+ from ..core.config import load_config
285
+ from ..beads import require_beads
286
+ from ..simple_mode.beads_hooks import create_workflow_issue
287
+
288
+ config = load_config(self.project_root / ".tapps-agents" / "config.yaml")
289
+ require_beads(config, self.project_root)
290
+ state_vars = self.state.variables or {}
291
+ # On resume: reuse id from .beads_issue_id file (same layout as *build)
292
+ state_dir = self._state_dir()
293
+ wf_dir = state_dir / workflow_id
294
+ beads_file = wf_dir / ".beads_issue_id"
295
+ if beads_file.exists():
296
+ try:
297
+ bid = beads_file.read_text(encoding="utf-8").strip() or None
298
+ if bid:
299
+ state_vars["_beads_issue_id"] = bid
300
+ self.state.variables = state_vars
301
+ except OSError:
302
+ pass
303
+ if "_beads_issue_id" not in state_vars:
304
+ bid = create_workflow_issue(
305
+ self.project_root,
306
+ config,
307
+ workflow.name,
308
+ user_prompt or state_vars.get("target_file", "") or "",
309
+ )
310
+ if bid:
311
+ state_vars["_beads_issue_id"] = bid
312
+ self.state.variables = state_vars
313
+ try:
314
+ wf_dir.mkdir(parents=True, exist_ok=True)
315
+ beads_file.write_text(bid, encoding="utf-8")
316
+ except OSError:
317
+ pass
318
+ except Exception as e:
319
+ from ..beads import BeadsRequiredError
320
+
321
+ if isinstance(e, BeadsRequiredError):
322
+ raise
323
+ pass # log-and-continue: do not fail start for other beads errors
324
+
325
+ # Generate and save execution plan (Epic 6 - Story 6.7)
326
+ try:
327
+ from .execution_plan import generate_execution_plan, save_execution_plan
328
+ execution_plan = generate_execution_plan(workflow)
329
+ state_dir = self._state_dir()
330
+ plan_path = save_execution_plan(execution_plan, state_dir, workflow_id)
331
+ if self.logger:
332
+ self.logger.info(
333
+ f"Execution plan generated: {plan_path}",
334
+ execution_plan_path=str(plan_path),
335
+ )
336
+ except Exception as e:
337
+ # Don't fail workflow start if execution plan generation fails
338
+ if self.logger:
339
+ self.logger.warning(f"Failed to generate execution plan: {e}")
340
+
341
+ self.logger.info(
342
+ "Workflow started",
343
+ workflow_name=workflow.name,
344
+ workflow_version=workflow.version,
345
+ step_count=len(workflow.steps),
346
+ )
347
+
348
+ # Publish workflow started event (Phase 2)
349
+ await self.event_bus.publish(
350
+ WorkflowEvent(
351
+ event_type=EventType.WORKFLOW_STARTED,
352
+ workflow_id=workflow_id,
353
+ step_id=None,
354
+ data={
355
+ "workflow_name": workflow.name,
356
+ "workflow_version": workflow.version,
357
+ "step_count": len(workflow.steps),
358
+ "user_prompt": user_prompt or "",
359
+ },
360
+ timestamp=datetime.now(),
361
+ correlation_id=workflow_id,
362
+ )
363
+ )
364
+
365
+ # Initialize progress update manager
366
+ self.progress_manager = ProgressUpdateManager(
367
+ workflow=workflow,
368
+ state=self.state,
369
+ project_root=self.project_root,
370
+ enable_updates=True,
371
+ )
372
+ # Connect event bus to status monitor (Phase 2)
373
+ if self.progress_manager.status_monitor:
374
+ self.progress_manager.status_monitor.event_bus = self.event_bus
375
+ # Start progress monitoring (non-blocking)
376
+ import asyncio
377
+ try:
378
+ asyncio.get_running_loop()
379
+ asyncio.create_task(self.progress_manager.start())
380
+ except RuntimeError:
381
+ # No running event loop - progress manager will start when event loop is available
382
+ pass
383
+
384
+ self.save_state()
385
+
386
+ # Generate task manifest (Epic 7)
387
+ self._generate_manifest()
388
+
389
+ return self.state
390
+
391
+ def save_state(self) -> None:
392
+ """Save workflow state to disk."""
393
+ if not self.state:
394
+ return
395
+
396
+ def _make_json_serializable(obj: Any) -> Any:
397
+ """Recursively convert objects to JSON-serializable format."""
398
+ # Handle ProjectProfile objects
399
+ if hasattr(obj, "to_dict") and hasattr(obj, "compliance_requirements"):
400
+ try:
401
+ from ..core.project_profile import ProjectProfile
402
+ if isinstance(obj, ProjectProfile):
403
+ return obj.to_dict()
404
+ except (ImportError, AttributeError):
405
+ pass
406
+
407
+ # Handle ComplianceRequirement objects
408
+ if hasattr(obj, "name") and hasattr(obj, "confidence") and hasattr(obj, "indicators"):
409
+ try:
410
+ from ..core.project_profile import ComplianceRequirement
411
+ if isinstance(obj, ComplianceRequirement):
412
+ return asdict(obj)
413
+ except (ImportError, AttributeError):
414
+ pass
415
+
416
+ # Handle dictionaries recursively
417
+ if isinstance(obj, dict):
418
+ return {k: _make_json_serializable(v) for k, v in obj.items()}
419
+
420
+ # Handle lists recursively
421
+ if isinstance(obj, list):
422
+ return [_make_json_serializable(item) for item in obj]
423
+
424
+ # Handle other non-serializable types
425
+ try:
426
+ import json
427
+ json.dumps(obj)
428
+ return obj
429
+ except (TypeError, ValueError):
430
+ # For non-serializable types, convert to string as fallback
431
+ return str(obj)
432
+
433
+ state_file = self._state_dir() / f"{self.state.workflow_id}.json"
434
+ state_file.parent.mkdir(parents=True, exist_ok=True)
435
+
436
+ # Convert variables to JSON-serializable format
437
+ variables = self.state.variables or {}
438
+ serializable_variables = _make_json_serializable(variables)
439
+
440
+ # Convert to dict for JSON serialization
441
+ state_dict = {
442
+ "workflow_id": self.state.workflow_id,
443
+ "status": self.state.status,
444
+ "current_step": self.state.current_step,
445
+ "started_at": self.state.started_at.isoformat() if self.state.started_at else None,
446
+ "completed_steps": self.state.completed_steps,
447
+ "skipped_steps": self.state.skipped_steps,
448
+ "variables": serializable_variables,
449
+ "artifacts": {
450
+ name: {
451
+ "name": a.name,
452
+ "path": a.path,
453
+ "status": a.status,
454
+ "created_by": a.created_by,
455
+ "created_at": a.created_at.isoformat() if a.created_at else None,
456
+ "metadata": a.metadata,
457
+ }
458
+ for name, a in self.state.artifacts.items()
459
+ },
460
+ "step_executions": [
461
+ {
462
+ "step_id": se.step_id,
463
+ "agent": se.agent,
464
+ "action": se.action,
465
+ "started_at": se.started_at.isoformat() if se.started_at else None,
466
+ "completed_at": se.completed_at.isoformat() if se.completed_at else None,
467
+ "duration_seconds": se.duration_seconds,
468
+ "status": se.status,
469
+ "error": se.error,
470
+ }
471
+ for se in self.state.step_executions
472
+ ],
473
+ "error": self.state.error,
474
+ }
475
+
476
+ from .file_utils import atomic_write_json
477
+
478
+ atomic_write_json(state_file, state_dict, indent=2)
479
+
480
+ # Also save to history
481
+ history_dir = state_file.parent / "history"
482
+ history_dir.mkdir(exist_ok=True)
483
+ history_file = history_dir / state_file.name
484
+ atomic_write_json(history_file, state_dict, indent=2)
485
+
486
+ # Generate task manifest (Epic 7)
487
+ self._generate_manifest()
488
+
489
+ def _generate_manifest(self) -> None:
490
+ """
491
+ Generate and save task manifest (Epic 7).
492
+
493
+ Generates manifest on workflow start, step completion, and state save.
494
+ """
495
+ if not self.workflow or not self.state:
496
+ return
497
+
498
+ try:
499
+ from .manifest import (
500
+ generate_manifest,
501
+ save_manifest,
502
+ sync_manifest_to_project_root,
503
+ )
504
+
505
+ # Generate manifest
506
+ manifest_content = generate_manifest(self.workflow, self.state)
507
+
508
+ # Save to state directory
509
+ state_dir = self._state_dir()
510
+ manifest_path = save_manifest(manifest_content, state_dir, self.state.workflow_id)
511
+
512
+ # Optional: Sync to project root if configured
513
+ sync_enabled = os.getenv("TAPPS_AGENTS_MANIFEST_SYNC", "false").lower() == "true"
514
+ if sync_enabled:
515
+ sync_path = sync_manifest_to_project_root(manifest_content, self.project_root)
516
+ if self.logger:
517
+ self.logger.debug(
518
+ "Task manifest synced to project root",
519
+ manifest_path=str(manifest_path),
520
+ sync_path=str(sync_path),
521
+ )
522
+ elif self.logger:
523
+ self.logger.debug(
524
+ "Task manifest generated",
525
+ manifest_path=str(manifest_path),
526
+ )
527
+ except Exception as e:
528
+ # Don't fail workflow if manifest generation fails
529
+ if self.logger:
530
+ self.logger.warning(
531
+ "Failed to generate task manifest",
532
+ error=str(e),
533
+ )
534
+
535
+ async def run(
536
+ self,
537
+ workflow: Workflow | None = None,
538
+ target_file: str | None = None,
539
+ max_steps: int = 100,
540
+ ) -> WorkflowState:
541
+ """
542
+ Run workflow to completion with timeout protection.
543
+
544
+ Args:
545
+ workflow: Workflow to execute (if not already loaded)
546
+ target_file: Optional target file path
547
+ max_steps: Maximum number of steps to execute
548
+
549
+ Returns:
550
+ Final workflow state
551
+ """
552
+ import asyncio
553
+ from datetime import datetime
554
+
555
+ from tapps_agents.core.config import load_config
556
+
557
+ config = load_config()
558
+ # Use 2x step timeout for overall workflow timeout (default: 2 hours)
559
+ workflow_timeout = getattr(config.workflow, 'timeout_seconds', 3600.0) * 2
560
+
561
+ async def _run_workflow_inner() -> WorkflowState:
562
+ """Inner function to wrap actual execution for timeout protection."""
563
+ # Initialize execution
564
+ target_path = await self._initialize_run(workflow, target_file)
565
+
566
+ # Log workflow start
567
+ start_time = datetime.now()
568
+ if self.logger:
569
+ self.logger.info(
570
+ "Starting workflow execution",
571
+ extra={
572
+ "workflow_id": self.state.workflow_id if self.state else None,
573
+ "workflow_name": workflow.name if workflow else (self.workflow.name if self.workflow else None),
574
+ "max_steps": max_steps,
575
+ "total_steps": len(workflow.steps) if workflow else (len(self.workflow.steps) if self.workflow else 0),
576
+ "workflow_timeout": workflow_timeout,
577
+ }
578
+ )
579
+
580
+ # Use parallel execution for independent steps
581
+ steps_executed = 0
582
+ completed_step_ids = set(self.state.completed_steps)
583
+ running_step_ids: set[str] = set()
584
+
585
+ while (
586
+ self.state
587
+ and self.workflow
588
+ and self.state.status == "running"
589
+ ):
590
+ if steps_executed >= max_steps:
591
+ self._handle_max_steps_exceeded(max_steps)
592
+ break
593
+
594
+ # Find steps ready to execute (dependencies met)
595
+ ready_steps = self._find_ready_steps(
596
+ completed_step_ids, running_step_ids
597
+ )
598
+
599
+ if not ready_steps:
600
+ if self._handle_no_ready_steps(completed_step_ids):
601
+ break
602
+ continue
603
+
604
+ # Execute ready steps in parallel
605
+ running_step_ids.update(step.id for step in ready_steps)
606
+
607
+ # Store completed steps with their results for dependency validation (BUG-003B)
608
+ completed_step_results: dict[str, StepResult] = {}
609
+
610
+ async def execute_step_wrapper(step: WorkflowStep) -> dict[str, Any]:
611
+ """Wrapper to adapt _execute_step_for_parallel to parallel executor interface (BUG-003B fix)."""
612
+ # Validate dependencies before execution (BUG-003B)
613
+ can_execute, skip_reason = self._can_execute_step(step, completed_step_results)
614
+
615
+ if not can_execute:
616
+ # Create skipped StepResult
617
+ now = datetime.now()
618
+ skipped_result = StepResult(
619
+ step_id=step.id,
620
+ status="skipped",
621
+ success=False,
622
+ duration=0.0,
623
+ started_at=now,
624
+ completed_at=now,
625
+ skip_reason=skip_reason,
626
+ artifacts=[],
627
+ )
628
+ completed_step_results[step.id] = skipped_result
629
+
630
+ # Print skip message
631
+ from ..core.unicode_safe import safe_print
632
+ safe_print(f"\n⏭️ Skipping step '{step.id}': {skip_reason}\n")
633
+
634
+ # Return empty artifacts (step was skipped)
635
+ return {}
636
+
637
+ # Execute step
638
+ step_result = await self._execute_step_for_parallel(step=step, target_path=target_path)
639
+ completed_step_results[step.id] = step_result
640
+
641
+ # Check if step failed (BUG-003B)
642
+ if not step_result.success:
643
+ # Check if step is required
644
+ is_required = step.condition == "required"
645
+
646
+ if is_required:
647
+ # Halt workflow for required step failure
648
+ from ..core.unicode_safe import safe_print
649
+ safe_print(
650
+ f"\n❌ Workflow halted: Required step '{step.id}' failed\n"
651
+ f"Error: {step_result.error}\n"
652
+ )
653
+
654
+ # Update workflow status
655
+ if self.state:
656
+ self.state.status = "blocked"
657
+ self.state.error = step_result.error
658
+
659
+ # Raise error to stop execution
660
+ raise RuntimeError(step_result.error or "Step failed")
661
+
662
+ # Convert StepResult artifacts (list of names) back to dict format for compatibility
663
+ artifacts_dict: dict[str, dict[str, Any]] = {}
664
+ for artifact_name in step_result.artifacts:
665
+ artifacts_dict[artifact_name] = {
666
+ "name": artifact_name,
667
+ "path": artifact_name,
668
+ "status": "complete",
669
+ "created_by": step.id,
670
+ "created_at": step_result.completed_at.isoformat(),
671
+ }
672
+
673
+ return artifacts_dict
674
+
675
+ try:
676
+ results = await self.parallel_executor.execute_parallel(
677
+ steps=ready_steps,
678
+ execute_fn=execute_step_wrapper,
679
+ state=self.state,
680
+ )
681
+
682
+ # Process results and update state
683
+ should_break = await self._process_parallel_results(
684
+ results, completed_step_ids, running_step_ids
685
+ )
686
+ if should_break:
687
+ break
688
+
689
+ steps_executed += len(ready_steps)
690
+ self.save_state()
691
+
692
+ # Generate task manifest after step completion (Epic 7)
693
+ self._generate_manifest()
694
+
695
+ # Log progress every 10 steps
696
+ if steps_executed % 10 == 0 and self.logger:
697
+ elapsed = (datetime.now() - start_time).total_seconds()
698
+ self.logger.info(
699
+ f"Workflow progress: {steps_executed} steps executed in {elapsed:.1f}s",
700
+ extra={
701
+ "steps_executed": steps_executed,
702
+ "completed_steps": len(completed_step_ids),
703
+ "total_steps": len(self.workflow.steps),
704
+ "elapsed_seconds": elapsed,
705
+ }
706
+ )
707
+
708
+ except Exception as e:
709
+ self._handle_execution_error(e)
710
+ break
711
+
712
+ return await self._finalize_run(completed_step_ids)
713
+
714
+ # Wrap execution with timeout
715
+ try:
716
+ return await asyncio.wait_for(
717
+ _run_workflow_inner(),
718
+ timeout=workflow_timeout
719
+ )
720
+ except TimeoutError:
721
+ if self.state:
722
+ self.state.status = "failed"
723
+ self.state.error = f"Workflow timeout after {workflow_timeout}s"
724
+ self.save_state()
725
+ if self.logger:
726
+ self.logger.error(
727
+ f"Workflow execution exceeded {workflow_timeout}s timeout",
728
+ extra={
729
+ "workflow_id": self.state.workflow_id,
730
+ "timeout_seconds": workflow_timeout,
731
+ }
732
+ )
733
+ raise TimeoutError(
734
+ f"Workflow execution exceeded {workflow_timeout}s timeout. "
735
+ f"Increase timeout in config (workflow.timeout_seconds) or check for blocking operations."
736
+ ) from None
737
+ finally:
738
+ variables = (getattr(self.state, "variables", None) or {}) if self.state else {}
739
+ beads_issue_id = variables.get("_beads_issue_id")
740
+ if beads_issue_id is None and self.state:
741
+ wf_id = getattr(self.state, "workflow_id", None)
742
+ if wf_id:
743
+ beads_file = self._state_dir() / wf_id / ".beads_issue_id"
744
+ if beads_file.exists():
745
+ try:
746
+ beads_issue_id = beads_file.read_text(
747
+ encoding="utf-8"
748
+ ).strip() or None
749
+ except OSError:
750
+ pass
751
+ from ..simple_mode.beads_hooks import close_issue
752
+ close_issue(self.project_root, beads_issue_id)
753
+
754
+ async def _initialize_run(
755
+ self,
756
+ workflow: Workflow | None,
757
+ target_file: str | None,
758
+ ) -> Path | None:
759
+ """Initialize workflow execution with validation and return target path."""
760
+ if workflow:
761
+ self.workflow = workflow
762
+ if not self.workflow:
763
+ raise ValueError(
764
+ "No workflow loaded. Call start() or pass workflow."
765
+ )
766
+
767
+ # Validate workflow has steps
768
+ if not self.workflow.steps:
769
+ raise ValueError("Workflow has no steps to execute")
770
+
771
+ # Ensure we have a state
772
+ if not self.state or not self.state.workflow_id.startswith(f"{self.workflow.id}-"):
773
+ await self.start(workflow=self.workflow)
774
+
775
+ # Validate first step can be executed (no dependencies)
776
+ first_step = self.workflow.steps[0]
777
+ if not first_step.requires: # No dependencies
778
+ # First step should always be ready
779
+ if self.logger:
780
+ self.logger.info(
781
+ f"First step {first_step.id} has no dependencies - ready to execute",
782
+ extra={
783
+ "step_id": first_step.id,
784
+ "agent": first_step.agent,
785
+ "action": first_step.action,
786
+ }
787
+ )
788
+
789
+ # Establish target file
790
+ target_path: Path | None = None
791
+ if target_file:
792
+ target_path = (
793
+ (self.project_root / target_file)
794
+ if not Path(target_file).is_absolute()
795
+ else Path(target_file)
796
+ )
797
+ else:
798
+ target_path = self._default_target_file()
799
+
800
+ if target_path and self.state:
801
+ self.state.variables["target_file"] = str(target_path)
802
+
803
+ return target_path
804
+
805
+ def _handle_max_steps_exceeded(self, max_steps: int) -> None:
806
+ """Handle max steps exceeded."""
807
+ self.state.status = "failed"
808
+ self.state.error = f"Max steps exceeded ({max_steps}). Aborting."
809
+ self.save_state()
810
+
811
+ def get_workflow_health(self) -> dict[str, Any]:
812
+ """
813
+ Get workflow health diagnostics.
814
+
815
+ Returns:
816
+ Dictionary with workflow health information including:
817
+ - status: Current workflow status
818
+ - elapsed_seconds: Time since workflow started
819
+ - completed_steps: Number of completed steps
820
+ - total_steps: Total number of steps
821
+ - progress_percent: Percentage of steps completed
822
+ - time_since_last_step: Seconds since last step completed
823
+ - is_stuck: Whether workflow appears to be stuck (no progress in 5 minutes)
824
+ - current_step: Current step ID
825
+ - error: Error message if any
826
+ """
827
+ if not self.state:
828
+ return {"status": "not_started", "message": "Workflow not started"}
829
+
830
+ elapsed = (
831
+ (datetime.now() - self.state.started_at).total_seconds()
832
+ if self.state.started_at else 0
833
+ )
834
+ completed = len(self.state.completed_steps)
835
+ total = len(self.workflow.steps) if self.workflow else 0
836
+
837
+ # Check if stuck (no progress in last 5 minutes)
838
+ last_step_time = None
839
+ if self.state.step_executions:
840
+ completed_times = [
841
+ se.completed_at for se in self.state.step_executions
842
+ if se.completed_at
843
+ ]
844
+ if completed_times:
845
+ last_step_time = max(completed_times)
846
+
847
+ if not last_step_time:
848
+ last_step_time = self.state.started_at
849
+
850
+ time_since_last_step = (
851
+ (datetime.now() - last_step_time).total_seconds()
852
+ if last_step_time else elapsed
853
+ )
854
+ is_stuck = time_since_last_step > 300 # 5 minutes
855
+
856
+ return {
857
+ "status": self.state.status,
858
+ "elapsed_seconds": elapsed,
859
+ "completed_steps": completed,
860
+ "total_steps": total,
861
+ "progress_percent": (completed / total * 100) if total > 0 else 0,
862
+ "time_since_last_step": time_since_last_step,
863
+ "is_stuck": is_stuck,
864
+ "current_step": self.state.current_step,
865
+ "error": self.state.error,
866
+ }
867
+
868
+ def _find_ready_steps(
869
+ self,
870
+ completed_step_ids: set[str],
871
+ running_step_ids: set[str],
872
+ ) -> list[WorkflowStep]:
873
+ """Find steps ready to execute (dependencies met)."""
874
+ available_artifacts = set(self.state.artifacts.keys())
875
+ return self.parallel_executor.find_ready_steps(
876
+ workflow_steps=self.workflow.steps,
877
+ completed_step_ids=completed_step_ids,
878
+ running_step_ids=running_step_ids,
879
+ available_artifacts=available_artifacts,
880
+ )
881
+
882
+ def _handle_no_ready_steps(self, completed_step_ids: set[str]) -> bool:
883
+ """Handle case when no steps are ready with better diagnostics. Returns True if workflow should stop."""
884
+ if len(completed_step_ids) >= len(self.workflow.steps):
885
+ # Workflow is complete
886
+ self.state.status = "completed"
887
+ self.state.current_step = None
888
+ self.save_state()
889
+ return True
890
+ else:
891
+ # Workflow is blocked - provide diagnostics
892
+ available_artifacts = set(self.state.artifacts.keys())
893
+ pending_steps = [
894
+ s for s in self.workflow.steps
895
+ if s.id not in completed_step_ids
896
+ ]
897
+
898
+ # Check what's blocking
899
+ blocking_info = []
900
+ for step in pending_steps:
901
+ missing = [req for req in (step.requires or []) if req not in available_artifacts]
902
+ if missing:
903
+ blocking_info.append(f"Step {step.id} ({step.agent}/{step.action}): missing {missing}")
904
+
905
+ error_msg = (
906
+ f"Workflow blocked: no ready steps and workflow not complete. "
907
+ f"Completed: {len(completed_step_ids)}/{len(self.workflow.steps)}. "
908
+ f"Blocking issues: {blocking_info if blocking_info else 'Unknown - check step dependencies'}"
909
+ )
910
+
911
+ self.state.status = "failed"
912
+ self.state.error = error_msg
913
+ self.save_state()
914
+
915
+ # Log detailed diagnostics
916
+ if self.logger:
917
+ self.logger.error(
918
+ "Workflow blocked - no ready steps",
919
+ extra={
920
+ "completed_steps": list(completed_step_ids),
921
+ "pending_steps": [s.id for s in pending_steps],
922
+ "available_artifacts": list(available_artifacts),
923
+ "blocking_info": blocking_info,
924
+ }
925
+ )
926
+
927
+ return True
928
+
929
+ async def _process_parallel_results(
930
+ self,
931
+ results: list[Any],
932
+ completed_step_ids: set[str],
933
+ running_step_ids: set[str],
934
+ ) -> bool:
935
+ """
936
+ Process results from parallel execution.
937
+ Returns True if workflow should stop (failed or aborted).
938
+ """
939
+ for result in results:
940
+ step_logger = self.logger.with_context(
941
+ step_id=result.step.id,
942
+ agent=result.step.agent,
943
+ ) if self.logger else None
944
+
945
+ if result.error:
946
+ should_break = await self._handle_step_error(
947
+ result, step_logger, completed_step_ids, running_step_ids
948
+ )
949
+ if should_break:
950
+ return True
951
+ continue
952
+
953
+ # Handle successful step completion
954
+ await self._handle_step_success(
955
+ result, step_logger, completed_step_ids, running_step_ids
956
+ )
957
+
958
+ return False
959
+
960
+ async def _handle_step_error(
961
+ self,
962
+ result: Any,
963
+ step_logger: Any,
964
+ completed_step_ids: set[str],
965
+ running_step_ids: set[str],
966
+ ) -> bool:
967
+ """Handle step error. Returns True if workflow should stop."""
968
+ # Publish step failed event (Phase 2)
969
+ await self.event_bus.publish(
970
+ WorkflowEvent(
971
+ event_type=EventType.STEP_FAILED,
972
+ workflow_id=self.state.workflow_id,
973
+ step_id=result.step.id,
974
+ data={
975
+ "agent": result.step.agent,
976
+ "action": result.step.action,
977
+ "error": str(result.error),
978
+ "attempts": getattr(result, "attempts", 1),
979
+ },
980
+ timestamp=datetime.now(),
981
+ correlation_id=f"{self.state.workflow_id}:{result.step.id}",
982
+ )
983
+ )
984
+
985
+ # Step failed - use error recovery and auto-progression (Epic 14)
986
+ error_context = ErrorContext(
987
+ workflow_id=self.state.workflow_id,
988
+ step_id=result.step.id,
989
+ agent=result.step.agent,
990
+ action=result.step.action,
991
+ step_number=None,
992
+ total_steps=len(self.workflow.steps),
993
+ workflow_status=self.state.status,
994
+ )
995
+
996
+ # Handle error with recovery manager (Epic 14)
997
+ recovery_result = None
998
+ user_friendly_error = None
999
+ if self.error_recovery:
1000
+ recovery_result = self.error_recovery.handle_error(
1001
+ error=result.error,
1002
+ context=error_context,
1003
+ attempt=getattr(result, "attempts", 1),
1004
+ )
1005
+
1006
+ # Store user-friendly message (can't modify frozen dataclass)
1007
+ if recovery_result.get("user_message"):
1008
+ user_friendly_error = recovery_result["user_message"]
1009
+
1010
+ if self.auto_progression.should_auto_progress():
1011
+ # Get review result if this was a reviewer step
1012
+ review_result = None
1013
+ if result.step.agent == "reviewer":
1014
+ review_result = self.state.variables.get("reviewer_result")
1015
+
1016
+ decision = self.auto_progression.handle_step_completion(
1017
+ step=result.step,
1018
+ state=self.state,
1019
+ step_execution=result.step_execution,
1020
+ review_result=review_result,
1021
+ )
1022
+
1023
+ if decision.action == ProgressionAction.RETRY:
1024
+ # Retry the step - remove from completed and add back to ready
1025
+ completed_step_ids.discard(result.step.id)
1026
+ running_step_ids.discard(result.step.id)
1027
+ # Apply backoff if specified
1028
+ if decision.metadata.get("backoff_seconds"):
1029
+ await asyncio.sleep(decision.metadata["backoff_seconds"])
1030
+ if step_logger:
1031
+ step_logger.info(
1032
+ f"Retrying step {result.step.id} (attempt {decision.retry_count})",
1033
+ )
1034
+ return False
1035
+ elif decision.action == ProgressionAction.SKIP:
1036
+ # Skip this step
1037
+ completed_step_ids.add(result.step.id)
1038
+ running_step_ids.discard(result.step.id)
1039
+ if result.step.id not in self.state.skipped_steps:
1040
+ self.state.skipped_steps.append(result.step.id)
1041
+ if step_logger:
1042
+ step_logger.warning(
1043
+ f"Skipping step {result.step.id}: {decision.reason}",
1044
+ )
1045
+ return False
1046
+ elif decision.action == ProgressionAction.ABORT:
1047
+ # Abort workflow
1048
+ self.state.status = "failed"
1049
+ self.state.error = decision.reason
1050
+ if step_logger:
1051
+ step_logger.error(
1052
+ f"Workflow aborted: {decision.reason}",
1053
+ )
1054
+
1055
+ # Publish workflow failed event (Phase 2)
1056
+ await self.event_bus.publish(
1057
+ WorkflowEvent(
1058
+ event_type=EventType.WORKFLOW_FAILED,
1059
+ workflow_id=self.state.workflow_id,
1060
+ step_id=result.step.id,
1061
+ data={
1062
+ "error": decision.reason,
1063
+ "step_id": result.step.id,
1064
+ },
1065
+ timestamp=datetime.now(),
1066
+ correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1067
+ )
1068
+ )
1069
+
1070
+ self.save_state()
1071
+ if self.progress_manager:
1072
+ await self.progress_manager.send_workflow_failed(decision.reason)
1073
+ await self.progress_manager.stop()
1074
+ return True
1075
+ elif decision.action == ProgressionAction.CONTINUE:
1076
+ # Continue despite error (recoverable)
1077
+ completed_step_ids.add(result.step.id)
1078
+ running_step_ids.discard(result.step.id)
1079
+ if step_logger:
1080
+ step_logger.warning(
1081
+ f"Step {result.step.id} failed but continuing: {decision.reason}",
1082
+ )
1083
+ return False
1084
+
1085
+ # Fallback: WorkflowFailureConfig when auto-progression disabled (plan 3.1)
1086
+ error_message = user_friendly_error if user_friendly_error else str(result.error)
1087
+ try:
1088
+ from ..core.config import load_config
1089
+
1090
+ cfg = load_config()
1091
+ wf = getattr(cfg, "workflow", None)
1092
+ fail_cfg = getattr(wf, "failure", None) if wf else None
1093
+ except Exception: # pylint: disable=broad-except
1094
+ fail_cfg = None
1095
+ on_fail = getattr(fail_cfg, "on_step_fail", "fail") or "fail"
1096
+ retry_count = getattr(fail_cfg, "retry_count", 1) or 0
1097
+ escalate_pause = getattr(fail_cfg, "escalate_to_pause", True)
1098
+
1099
+ raw = self.state.variables.get("_step_retries")
1100
+ retries_var = raw if isinstance(raw, dict) else {}
1101
+ self.state.variables["_step_retries"] = retries_var
1102
+ retries_used = retries_var.get(result.step.id, 0)
1103
+
1104
+ if on_fail == "retry" and retries_used < retry_count:
1105
+ retries_var[result.step.id] = retries_used + 1
1106
+ completed_step_ids.discard(result.step.id)
1107
+ running_step_ids.discard(result.step.id)
1108
+ if step_logger:
1109
+ step_logger.info(f"Retrying step {result.step.id} (attempt {retries_used + 1}/{retry_count})")
1110
+ return False
1111
+
1112
+ if on_fail == "skip":
1113
+ completed_step_ids.add(result.step.id)
1114
+ running_step_ids.discard(result.step.id)
1115
+ if result.step.id not in self.state.skipped_steps:
1116
+ self.state.skipped_steps.append(result.step.id)
1117
+ if step_logger:
1118
+ step_logger.warning(f"Skipping step {result.step.id}: {error_message}")
1119
+ return False
1120
+
1121
+ # fail or escalate: stop workflow
1122
+ self.state.status = "paused" if (on_fail == "escalate" and escalate_pause) else "failed"
1123
+ self.state.error = f"Step {result.step.id} failed: {error_message}"
1124
+ suggest = None
1125
+ if on_fail == "escalate" and recovery_result and recovery_result.get("suggestions"):
1126
+ suggest = [getattr(s, "action", str(s)) for s in recovery_result["suggestions"][:3]]
1127
+
1128
+ # Publish workflow failed event (Phase 2)
1129
+ await self.event_bus.publish(
1130
+ WorkflowEvent(
1131
+ event_type=EventType.WORKFLOW_FAILED,
1132
+ workflow_id=self.state.workflow_id,
1133
+ step_id=result.step.id,
1134
+ data={
1135
+ "error": error_message,
1136
+ "step_id": result.step.id,
1137
+ "behavior": on_fail,
1138
+ "suggestions": suggest,
1139
+ },
1140
+ timestamp=datetime.now(),
1141
+ correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1142
+ )
1143
+ )
1144
+
1145
+ self.save_state()
1146
+
1147
+ # Send failure update
1148
+ if self.progress_manager:
1149
+ await self.progress_manager.send_workflow_failed(error_message)
1150
+ await self.progress_manager.stop()
1151
+ return True
1152
+
1153
+ async def _handle_step_success(
1154
+ self,
1155
+ result: Any,
1156
+ step_logger: Any,
1157
+ completed_step_ids: set[str],
1158
+ running_step_ids: set[str],
1159
+ ) -> None:
1160
+ """Handle successful step completion."""
1161
+ # Mark step as completed
1162
+ completed_step_ids.add(result.step.id)
1163
+ running_step_ids.discard(result.step.id)
1164
+
1165
+ # Get review result if this was a reviewer step (for gate evaluation)
1166
+ review_result = None
1167
+ if result.step.agent == "reviewer":
1168
+ review_result = self.state.variables.get("reviewer_result")
1169
+
1170
+ # Issue fix: Print artifact paths after each step (Hidden workflow state)
1171
+ if self.print_paths and result.artifacts:
1172
+ self._print_step_artifacts(result.step, result.artifacts, result.step_execution)
1173
+
1174
+ # Publish step completed event (Phase 2)
1175
+ await self.event_bus.publish(
1176
+ WorkflowEvent(
1177
+ event_type=EventType.STEP_COMPLETED,
1178
+ workflow_id=self.state.workflow_id,
1179
+ step_id=result.step.id,
1180
+ data={
1181
+ "agent": result.step.agent,
1182
+ "action": result.step.action,
1183
+ "duration_seconds": result.step_execution.duration_seconds,
1184
+ "artifact_count": len(result.artifacts) if result.artifacts else 0,
1185
+ },
1186
+ timestamp=datetime.now(),
1187
+ correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1188
+ )
1189
+ )
1190
+
1191
+ # Publish artifact created events (Phase 2)
1192
+ if result.artifacts:
1193
+ for artifact_name, artifact_data in result.artifacts.items():
1194
+ await self.event_bus.publish(
1195
+ WorkflowEvent(
1196
+ event_type=EventType.ARTIFACT_CREATED,
1197
+ workflow_id=self.state.workflow_id,
1198
+ step_id=result.step.id,
1199
+ data={
1200
+ "artifact_name": artifact_name,
1201
+ "artifact_path": artifact_data.get("path", ""),
1202
+ "created_by": result.step.id,
1203
+ },
1204
+ timestamp=datetime.now(),
1205
+ correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1206
+ )
1207
+ )
1208
+
1209
+ # Use auto-progression to handle step completion and gate evaluation
1210
+ if self.auto_progression.should_auto_progress():
1211
+ decision = self.auto_progression.handle_step_completion(
1212
+ step=result.step,
1213
+ state=self.state,
1214
+ step_execution=result.step_execution,
1215
+ review_result=review_result,
1216
+ )
1217
+
1218
+ # Update current step based on gate decision if needed
1219
+ if decision.next_step_id:
1220
+ self.state.current_step = decision.next_step_id
1221
+
1222
+ if step_logger:
1223
+ step_logger.info(
1224
+ f"Step completed: {decision.reason}",
1225
+ action=result.step.action,
1226
+ duration_seconds=result.step_execution.duration_seconds,
1227
+ artifact_count=len(result.artifacts) if result.artifacts else 0,
1228
+ next_step=decision.next_step_id,
1229
+ )
1230
+ else:
1231
+ if step_logger:
1232
+ step_logger.info(
1233
+ "Step completed",
1234
+ action=result.step.action,
1235
+ duration_seconds=result.step_execution.duration_seconds,
1236
+ artifact_count=len(result.artifacts) if result.artifacts else 0,
1237
+ )
1238
+
1239
+ # Send step completed update (Epic 11: Include gate result for quality dashboard)
1240
+ is_gate_step = result.step.agent == "reviewer" and result.step.gate is not None
1241
+ if self.progress_manager:
1242
+ # Extract gate result if this was a reviewer step
1243
+ gate_result = None
1244
+ if result.step.agent == "reviewer" and review_result:
1245
+ # Get gate result from state variables (set by auto-progression)
1246
+ gate_last = self.state.variables.get("gate_last", {})
1247
+ if gate_last:
1248
+ gate_result = gate_last
1249
+
1250
+ # Publish gate evaluated event (Phase 2)
1251
+ await self.event_bus.publish(
1252
+ WorkflowEvent(
1253
+ event_type=EventType.GATE_EVALUATED,
1254
+ workflow_id=self.state.workflow_id,
1255
+ step_id=result.step.id,
1256
+ data={
1257
+ "gate_result": gate_result,
1258
+ "passed": gate_result.get("passed", False),
1259
+ },
1260
+ timestamp=datetime.now(),
1261
+ correlation_id=f"{self.state.workflow_id}:{result.step.id}",
1262
+ )
1263
+ )
1264
+
1265
+ await self.progress_manager.send_step_completed(
1266
+ step_id=result.step.id,
1267
+ agent=result.step.agent,
1268
+ action=result.step.action,
1269
+ duration=result.step_execution.duration_seconds,
1270
+ gate_result=gate_result,
1271
+ )
1272
+
1273
+ # Epic 12: Automatic checkpointing after step completion
1274
+ if self.checkpoint_manager.should_checkpoint(
1275
+ step=result.step,
1276
+ state=self.state,
1277
+ is_gate_step=is_gate_step,
1278
+ ):
1279
+ # Enhance state with checkpoint metadata before saving
1280
+ checkpoint_metadata = self.checkpoint_manager.get_checkpoint_metadata(
1281
+ state=self.state,
1282
+ step=result.step,
1283
+ )
1284
+ # Store metadata in state variables for persistence
1285
+ if "_checkpoint_metadata" not in self.state.variables:
1286
+ self.state.variables["_checkpoint_metadata"] = {}
1287
+ self.state.variables["_checkpoint_metadata"].update(checkpoint_metadata)
1288
+
1289
+ # Save checkpoint
1290
+ self.save_state()
1291
+ self.checkpoint_manager.record_checkpoint(result.step.id)
1292
+
1293
+ if self.logger:
1294
+ self.logger.info(
1295
+ f"Checkpoint created after step {result.step.id}",
1296
+ checkpoint_metadata=checkpoint_metadata,
1297
+ )
1298
+
1299
+ # Update artifacts from result
1300
+ if result.artifacts and isinstance(result.artifacts, dict):
1301
+ for art_name, art_data in result.artifacts.items():
1302
+ if isinstance(art_data, dict):
1303
+ artifact = Artifact(
1304
+ name=art_data.get("name", art_name),
1305
+ path=art_data.get("path", ""),
1306
+ status="complete",
1307
+ created_by=result.step.id,
1308
+ created_at=datetime.now(),
1309
+ metadata=art_data.get("metadata", {}),
1310
+ )
1311
+ self.state.artifacts[artifact.name] = artifact
1312
+
1313
+ def _handle_execution_error(self, error: Exception) -> None:
1314
+ """Handle execution error."""
1315
+ self.state.status = "failed"
1316
+ self.state.error = str(error)
1317
+ if self.logger:
1318
+ self.logger.error(
1319
+ "Workflow execution failed",
1320
+ error=str(error),
1321
+ exc_info=True,
1322
+ )
1323
+ self.save_state()
1324
+
1325
+ async def _finalize_run(self, completed_step_ids: set[str]) -> WorkflowState:
1326
+ """Finalize workflow execution and return final state."""
1327
+ if not self.state:
1328
+ raise RuntimeError("Workflow state lost during execution")
1329
+
1330
+ # Mark as completed if no error
1331
+ if self.state.status == "running":
1332
+ self.state.status = "completed"
1333
+ if self.logger:
1334
+ self.logger.info(
1335
+ "Workflow completed",
1336
+ completed_steps=len(completed_step_ids),
1337
+ total_steps=len(self.workflow.steps) if self.workflow else 0,
1338
+ )
1339
+
1340
+ # Publish workflow completed event (Phase 2)
1341
+ await self.event_bus.publish(
1342
+ WorkflowEvent(
1343
+ event_type=EventType.WORKFLOW_COMPLETED,
1344
+ workflow_id=self.state.workflow_id,
1345
+ step_id=None,
1346
+ data={
1347
+ "completed_steps": len(completed_step_ids),
1348
+ "total_steps": len(self.workflow.steps) if self.workflow else 0,
1349
+ },
1350
+ timestamp=datetime.now(),
1351
+ correlation_id=self.state.workflow_id,
1352
+ )
1353
+ )
1354
+
1355
+ self.save_state()
1356
+
1357
+ # Send completion summary
1358
+ if self.progress_manager:
1359
+ await self.progress_manager.send_workflow_completed()
1360
+ await self.progress_manager.stop()
1361
+
1362
+ # Best-effort cleanup of worktrees created during this run
1363
+ try:
1364
+ await self.worktree_manager.cleanup_all()
1365
+ except Exception:
1366
+ pass
1367
+
1368
+ # Dual-write workflow completion to analytics (best-effort)
1369
+ if self.state.status in ("completed", "failed") and self.workflow:
1370
+ try:
1371
+ from .analytics_dual_write import record_workflow_execution_to_analytics
1372
+
1373
+ duration_sec = 0.0
1374
+ if self.state.started_at:
1375
+ end = datetime.now()
1376
+ duration_sec = (end - self.state.started_at).total_seconds()
1377
+ record_workflow_execution_to_analytics(
1378
+ project_root=self.project_root,
1379
+ workflow_id=self.state.workflow_id,
1380
+ workflow_name=self.workflow.name or self.state.workflow_id,
1381
+ duration_seconds=duration_sec,
1382
+ steps=len(self.workflow.steps),
1383
+ success=(self.state.status == "completed"),
1384
+ )
1385
+ except Exception: # pylint: disable=broad-except
1386
+ pass
1387
+
1388
+ return self.state
1389
+
1390
+ async def _execute_step_for_parallel(
1391
+ self, step: WorkflowStep, target_path: Path | None
1392
+ ) -> StepResult:
1393
+ """
1394
+ Execute a single workflow step using Cursor Skills and return result (BUG-003B fix).
1395
+
1396
+ This method now returns StepResult with proper error handling:
1397
+ - success=True + artifacts on success
1398
+ - success=False + error details on failure (no exception raised)
1399
+
1400
+ State updates (step_execution tracking) are handled by ParallelStepExecutor.
1401
+ """
1402
+ if not self.state or not self.workflow:
1403
+ raise ValueError("Workflow not started")
1404
+
1405
+ action = self._normalize_action(step.action)
1406
+ agent_name = (step.agent or "").strip().lower()
1407
+
1408
+ # Publish step started event (Phase 2)
1409
+ await self.event_bus.publish(
1410
+ WorkflowEvent(
1411
+ event_type=EventType.STEP_STARTED,
1412
+ workflow_id=self.state.workflow_id,
1413
+ step_id=step.id,
1414
+ data={
1415
+ "agent": agent_name,
1416
+ "action": action,
1417
+ "step_id": step.id,
1418
+ },
1419
+ timestamp=datetime.now(),
1420
+ correlation_id=f"{self.state.workflow_id}:{step.id}",
1421
+ )
1422
+ )
1423
+
1424
+ # Handle completion/finalization steps that don't require agent execution
1425
+ if agent_name == "orchestrator" and action in ["finalize", "complete"]:
1426
+ # Return successful result for completion steps (no artifacts)
1427
+ now = datetime.now()
1428
+ return StepResult(
1429
+ step_id=step.id,
1430
+ status="completed",
1431
+ success=True,
1432
+ duration=0.0,
1433
+ started_at=now,
1434
+ completed_at=now,
1435
+ artifacts=[],
1436
+ )
1437
+
1438
+ # Track step start time for duration calculation
1439
+ step_started_at = datetime.now()
1440
+
1441
+ # Use context manager for worktree lifecycle (guaranteed cleanup)
1442
+ async with self._worktree_context(step) as worktree_path:
1443
+ worktree_name = self._worktree_name_for_step(step.id)
1444
+
1445
+ # Try AgentHandlerRegistry first for context-aware execution (BUG-003 fix)
1446
+ # Falls back to SkillInvoker if no handler found
1447
+ from .agent_handlers import AgentHandlerRegistry
1448
+
1449
+ # Helper function to run agents (needed by handlers)
1450
+ async def run_agent(agent: str, command: str, **kwargs: Any) -> dict[str, Any]:
1451
+ """Run agent by importing and invoking its class."""
1452
+ module = __import__(f"tapps_agents.agents.{agent}.agent", fromlist=["*"])
1453
+ class_name = f"{agent.title()}Agent"
1454
+ agent_cls = getattr(module, class_name)
1455
+ instance = agent_cls()
1456
+ await instance.activate(self.project_root)
1457
+ try:
1458
+ return await instance.run(command, **kwargs)
1459
+ finally:
1460
+ if hasattr(instance, 'close'):
1461
+ await instance.close()
1462
+
1463
+ # Create handler registry and try to find handler
1464
+ registry = AgentHandlerRegistry.create_registry(
1465
+ project_root=self.project_root,
1466
+ state=self.state,
1467
+ workflow=self.workflow,
1468
+ run_agent_fn=run_agent,
1469
+ executor=self,
1470
+ )
1471
+
1472
+ handler = registry.find_handler(agent_name, action)
1473
+
1474
+ try:
1475
+ from ..core.unicode_safe import safe_print
1476
+
1477
+ if handler:
1478
+ # Use handler for context-aware execution (e.g., ImplementerHandler)
1479
+ safe_print(f"\n[EXEC] Executing {agent_name}/{action} via handler...", flush=True)
1480
+
1481
+ # Execute handler and get artifacts directly
1482
+ # Note: Handler execution happens in main working directory, not worktree
1483
+ # Worktree is only used for skill invocation fallback
1484
+ created_artifacts_list = await handler.execute(step, action, target_path)
1485
+
1486
+ # Write success marker
1487
+ step_completed_at = datetime.now()
1488
+ duration = (step_completed_at - step_started_at).total_seconds()
1489
+
1490
+ found_artifact_paths = [art["path"] for art in (created_artifacts_list or [])]
1491
+ artifact_names = [art["name"] for art in (created_artifacts_list or [])]
1492
+
1493
+ marker_path = self.marker_writer.write_done_marker(
1494
+ workflow_id=self.state.workflow_id,
1495
+ step_id=step.id,
1496
+ agent=agent_name,
1497
+ action=action,
1498
+ worktree_name=worktree_name,
1499
+ worktree_path=str(worktree_path),
1500
+ expected_artifacts=step.creates or [],
1501
+ found_artifacts=found_artifact_paths,
1502
+ duration_seconds=duration,
1503
+ started_at=step_started_at,
1504
+ completed_at=step_completed_at,
1505
+ )
1506
+
1507
+ if self.logger:
1508
+ self.logger.debug(
1509
+ f"Handler execution complete for step {step.id}",
1510
+ marker_path=str(marker_path),
1511
+ )
1512
+
1513
+ # Return successful StepResult (BUG-003B fix)
1514
+ return StepResult(
1515
+ step_id=step.id,
1516
+ status="completed",
1517
+ success=True,
1518
+ duration=duration,
1519
+ started_at=step_started_at,
1520
+ completed_at=step_completed_at,
1521
+ artifacts=artifact_names,
1522
+ )
1523
+ else:
1524
+ # Fall back to SkillInvoker for steps without handlers
1525
+ safe_print(f"\n[EXEC] Executing {agent_name}/{action} via skill...", flush=True)
1526
+ await self.skill_invoker.invoke_skill(
1527
+ agent_name=agent_name,
1528
+ action=action,
1529
+ step=step,
1530
+ target_path=target_path,
1531
+ worktree_path=worktree_path,
1532
+ state=self.state,
1533
+ )
1534
+ # Skill invoker handles execution (direct execution or Cursor Skills)
1535
+ # Artifacts are extracted after completion
1536
+
1537
+ # Extract artifacts from worktree (skill_invoker path only)
1538
+ artifacts = await self.worktree_manager.extract_artifacts(
1539
+ worktree_path=worktree_path,
1540
+ step=step,
1541
+ )
1542
+
1543
+ # Extract artifact paths and names
1544
+ found_artifact_paths = []
1545
+ artifact_names = []
1546
+ for artifact in artifacts:
1547
+ found_artifact_paths.append(artifact.path)
1548
+ artifact_names.append(artifact.name)
1549
+
1550
+ # Write DONE marker for successful completion
1551
+ step_completed_at = datetime.now()
1552
+ duration = (step_completed_at - step_started_at).total_seconds()
1553
+
1554
+ marker_path = self.marker_writer.write_done_marker(
1555
+ workflow_id=self.state.workflow_id,
1556
+ step_id=step.id,
1557
+ agent=agent_name,
1558
+ action=action,
1559
+ worktree_name=worktree_name,
1560
+ worktree_path=str(worktree_path),
1561
+ expected_artifacts=step.creates or [],
1562
+ found_artifacts=found_artifact_paths,
1563
+ duration_seconds=duration,
1564
+ started_at=step_started_at,
1565
+ completed_at=step_completed_at,
1566
+ )
1567
+
1568
+ if self.logger:
1569
+ self.logger.debug(
1570
+ f"DONE marker written for step {step.id}",
1571
+ marker_path=str(marker_path),
1572
+ )
1573
+
1574
+ # Return successful StepResult (BUG-003B fix)
1575
+ # Worktree cleanup is handled by context manager
1576
+ return StepResult(
1577
+ step_id=step.id,
1578
+ status="completed",
1579
+ success=True,
1580
+ duration=duration,
1581
+ started_at=step_started_at,
1582
+ completed_at=step_completed_at,
1583
+ artifacts=artifact_names,
1584
+ )
1585
+
1586
+ except (TimeoutError, RuntimeError) as e:
1587
+ # Write FAILED marker for timeout or execution errors
1588
+ step_failed_at = datetime.now()
1589
+ duration = (step_failed_at - step_started_at).total_seconds()
1590
+ error_type = type(e).__name__
1591
+ error_msg = str(e)
1592
+ error_tb = traceback.format_exc()
1593
+
1594
+ # Try to get completion status if available (for missing artifacts)
1595
+ found_artifact_paths = []
1596
+ try:
1597
+ from .cursor_skill_helper import check_skill_completion
1598
+ completion_status = check_skill_completion(
1599
+ worktree_path=worktree_path,
1600
+ expected_artifacts=step.creates or [],
1601
+ )
1602
+ found_artifact_paths = completion_status.get("found_artifacts", [])
1603
+ except Exception:
1604
+ pass
1605
+
1606
+ marker_path = self.marker_writer.write_failed_marker(
1607
+ workflow_id=self.state.workflow_id,
1608
+ step_id=step.id,
1609
+ agent=agent_name,
1610
+ action=action,
1611
+ error=error_msg,
1612
+ worktree_name=worktree_name,
1613
+ worktree_path=str(worktree_path),
1614
+ expected_artifacts=step.creates or [],
1615
+ found_artifacts=found_artifact_paths,
1616
+ duration_seconds=duration,
1617
+ started_at=step_started_at,
1618
+ failed_at=step_failed_at,
1619
+ error_type=error_type,
1620
+ metadata={
1621
+ "marker_location": f".tapps-agents/workflows/markers/{self.state.workflow_id}/step-{step.id}/FAILED.json",
1622
+ },
1623
+ )
1624
+
1625
+ if self.logger:
1626
+ self.logger.warning(
1627
+ f"FAILED marker written for step {step.id}",
1628
+ marker_path=str(marker_path),
1629
+ error=error_msg,
1630
+ )
1631
+
1632
+ # Include marker location in error message for better troubleshooting
1633
+ from ..core.unicode_safe import safe_print
1634
+ safe_print(
1635
+ f"\n[INFO] Failure marker written to: {marker_path}",
1636
+ flush=True,
1637
+ )
1638
+
1639
+ # Return failed StepResult (BUG-003B fix - don't raise)
1640
+ return StepResult(
1641
+ step_id=step.id,
1642
+ status="failed",
1643
+ success=False,
1644
+ duration=duration,
1645
+ started_at=step_started_at,
1646
+ completed_at=step_failed_at,
1647
+ error=error_msg,
1648
+ error_traceback=error_tb,
1649
+ artifacts=[],
1650
+ )
1651
+ except Exception as e:
1652
+ # Write FAILED marker for unexpected errors
1653
+ step_failed_at = datetime.now()
1654
+ duration = (step_failed_at - step_started_at).total_seconds()
1655
+ error_type = type(e).__name__
1656
+ error_msg = str(e)
1657
+ error_tb = traceback.format_exc()
1658
+
1659
+ marker_path = self.marker_writer.write_failed_marker(
1660
+ workflow_id=self.state.workflow_id,
1661
+ step_id=step.id,
1662
+ agent=agent_name,
1663
+ action=action,
1664
+ error=error_msg,
1665
+ worktree_name=worktree_name,
1666
+ worktree_path=str(worktree_path) if 'worktree_path' in locals() else None,
1667
+ expected_artifacts=step.creates or [],
1668
+ found_artifacts=[],
1669
+ duration_seconds=duration,
1670
+ started_at=step_started_at,
1671
+ failed_at=step_failed_at,
1672
+ error_type=error_type,
1673
+ metadata={
1674
+ "marker_location": f".tapps-agents/workflows/markers/{self.state.workflow_id}/step-{step.id}/FAILED.json",
1675
+ },
1676
+ )
1677
+
1678
+ if self.logger:
1679
+ self.logger.error(
1680
+ f"FAILED marker written for step {step.id} (unexpected error)",
1681
+ marker_path=str(marker_path),
1682
+ error=error_msg,
1683
+ exc_info=True,
1684
+ )
1685
+
1686
+ # Return failed StepResult (BUG-003B fix - don't raise)
1687
+ return StepResult(
1688
+ step_id=step.id,
1689
+ status="failed",
1690
+ success=False,
1691
+ duration=duration,
1692
+ started_at=step_started_at,
1693
+ completed_at=step_failed_at,
1694
+ error=error_msg,
1695
+ error_traceback=error_tb,
1696
+ artifacts=[],
1697
+ )
1698
+
1699
+ @asynccontextmanager
1700
+ async def _worktree_context(
1701
+ self, step: WorkflowStep
1702
+ ) -> AsyncIterator[Path]:
1703
+ """
1704
+ Context manager for worktree lifecycle management.
1705
+
1706
+ Ensures worktree is properly cleaned up even on cancellation or exceptions.
1707
+ This is a 2025 best practice for resource management in async code.
1708
+
1709
+ Args:
1710
+ step: Workflow step that needs a worktree
1711
+
1712
+ Yields:
1713
+ Path to the worktree
1714
+
1715
+ Example:
1716
+ async with self._worktree_context(step) as worktree_path:
1717
+ # Use worktree_path here
1718
+ # Worktree automatically cleaned up on exit
1719
+ """
1720
+ worktree_name = self._worktree_name_for_step(step.id)
1721
+ worktree_path: Path | None = None
1722
+
1723
+ try:
1724
+ # Create worktree
1725
+ worktree_path = await self.worktree_manager.create_worktree(
1726
+ worktree_name=worktree_name
1727
+ )
1728
+
1729
+ # Copy artifacts from previous steps to worktree
1730
+ artifacts_list = list(self.state.artifacts.values())
1731
+ await self.worktree_manager.copy_artifacts(
1732
+ worktree_path=worktree_path,
1733
+ artifacts=artifacts_list,
1734
+ )
1735
+
1736
+ # Yield worktree path
1737
+ yield worktree_path
1738
+
1739
+ finally:
1740
+ # Always cleanup, even on cancellation or exception
1741
+ if worktree_path:
1742
+ try:
1743
+ # Determine if we should delete the branch based on configuration
1744
+ from ..core.config import load_config
1745
+ config = load_config()
1746
+ should_delete = (
1747
+ config.workflow.branch_cleanup.delete_branches_on_cleanup
1748
+ if (
1749
+ config.workflow.branch_cleanup
1750
+ and config.workflow.branch_cleanup.enabled
1751
+ )
1752
+ else True # Default to True for backward compatibility (same as parameter default)
1753
+ )
1754
+ await self.worktree_manager.remove_worktree(
1755
+ worktree_name, delete_branch=should_delete
1756
+ )
1757
+ except Exception as e:
1758
+ # Log but don't raise - cleanup failures shouldn't break workflow
1759
+ if self.logger:
1760
+ self.logger.warning(
1761
+ f"Failed to cleanup worktree {worktree_name}: {e}",
1762
+ step_id=step.id,
1763
+ )
1764
+
1765
+ def _worktree_name_for_step(self, step_id: str) -> str:
1766
+ """
1767
+ Deterministic, collision-resistant worktree name for a workflow step.
1768
+
1769
+ Keeps names short/safe for Windows while still traceable back to workflow+step.
1770
+ """
1771
+ if not self.state:
1772
+ raise ValueError("Workflow not started")
1773
+ raw = f"workflow-{self.state.workflow_id}-step-{step_id}"
1774
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
1775
+ base = f"{raw}-{digest}"
1776
+ return WorktreeManager._sanitize_component(base, max_len=80)
1777
+
1778
+ def get_current_step(self) -> WorkflowStep | None:
1779
+ """Get the current workflow step."""
1780
+ if not self.workflow or not self.state:
1781
+ return None
1782
+
1783
+ for step in self.workflow.steps:
1784
+ if step.id == self.state.current_step:
1785
+ return step
1786
+ return None
1787
+
1788
+ def _default_target_file(self) -> Path | None:
1789
+ """Get default target file path."""
1790
+ # Try common locations
1791
+ candidates = [
1792
+ self.project_root / "src" / "app.py",
1793
+ self.project_root / "app.py",
1794
+ self.project_root / "main.py",
1795
+ ]
1796
+ for candidate in candidates:
1797
+ if candidate.exists():
1798
+ return candidate
1799
+ return None
1800
+
1801
+ async def _execute_step(
1802
+ self, step: WorkflowStep, target_path: Path | None
1803
+ ) -> None:
1804
+ """
1805
+ Execute a single workflow step using Cursor Skills.
1806
+
1807
+ Args:
1808
+ step: Workflow step to execute
1809
+ target_path: Optional target file path
1810
+ """
1811
+ if not self.state or not self.workflow:
1812
+ raise ValueError("Workflow not started")
1813
+
1814
+ action = self._normalize_action(step.action)
1815
+ agent_name = (step.agent or "").strip().lower()
1816
+
1817
+ # Handle completion/finalization steps that don't require agent execution
1818
+ if agent_name == "orchestrator" and action in ["finalize", "complete"]:
1819
+ # Mark step as completed without executing an agent
1820
+ step_execution = StepExecution(
1821
+ step_id=step.id,
1822
+ agent=agent_name,
1823
+ action=action,
1824
+ started_at=datetime.now(),
1825
+ completed_at=datetime.now(),
1826
+ status="completed",
1827
+ )
1828
+ self.state.step_executions.append(step_execution)
1829
+ self._advance_step()
1830
+ self.save_state()
1831
+ return
1832
+
1833
+ # Create step execution tracking
1834
+ step_execution = StepExecution(
1835
+ step_id=step.id,
1836
+ agent=agent_name,
1837
+ action=action,
1838
+ started_at=datetime.now(),
1839
+ )
1840
+ self.state.step_executions.append(step_execution)
1841
+
1842
+ try:
1843
+ # Create worktree for this step
1844
+ worktree_name = self._worktree_name_for_step(step.id)
1845
+ worktree_path = await self.worktree_manager.create_worktree(
1846
+ worktree_name=worktree_name
1847
+ )
1848
+
1849
+ # Copy artifacts from previous steps to worktree
1850
+ artifacts_list = list(self.state.artifacts.values())
1851
+ await self.worktree_manager.copy_artifacts(
1852
+ worktree_path=worktree_path,
1853
+ artifacts=artifacts_list,
1854
+ )
1855
+
1856
+ # Invoke Skill via SkillInvoker (direct execution)
1857
+ result = await self.skill_invoker.invoke_skill(
1858
+ agent_name=agent_name,
1859
+ action=action,
1860
+ step=step,
1861
+ target_path=target_path,
1862
+ worktree_path=worktree_path,
1863
+ state=self.state,
1864
+ )
1865
+
1866
+ # Wait for Skill to complete (direct execution)
1867
+ # Poll for artifacts or completion marker
1868
+ import asyncio
1869
+
1870
+ from .cursor_skill_helper import check_skill_completion
1871
+
1872
+ max_wait_time = 3600 # 1 hour max wait
1873
+ poll_interval = 2 # Check every 2 seconds
1874
+ elapsed = 0
1875
+
1876
+ print(f"Waiting for {agent_name}/{action} to complete...")
1877
+ while elapsed < max_wait_time:
1878
+ completion_status = check_skill_completion(
1879
+ worktree_path=worktree_path,
1880
+ expected_artifacts=step.creates,
1881
+ )
1882
+
1883
+ if completion_status["completed"]:
1884
+ from ..core.unicode_safe import safe_print
1885
+ safe_print(f"[OK] {agent_name}/{action} completed - found artifacts: {completion_status['found_artifacts']}")
1886
+ break
1887
+
1888
+ await asyncio.sleep(poll_interval)
1889
+ elapsed += poll_interval
1890
+
1891
+ # Print progress every 10 seconds
1892
+ if elapsed % 10 == 0:
1893
+ print(f" Still waiting... ({elapsed}s elapsed)")
1894
+ else:
1895
+ raise TimeoutError(
1896
+ f"Skill {agent_name}/{action} did not complete within {max_wait_time}s. "
1897
+ f"Expected artifacts: {step.creates}, Missing: {completion_status.get('missing_artifacts', [])}"
1898
+ )
1899
+
1900
+ # Extract artifacts from worktree
1901
+ artifacts = await self.worktree_manager.extract_artifacts(
1902
+ worktree_path=worktree_path,
1903
+ step=step,
1904
+ )
1905
+
1906
+ # Update state with artifacts
1907
+ for artifact in artifacts:
1908
+ self.state.artifacts[artifact.name] = artifact
1909
+
1910
+ # Story-level step handling (Phase 3: Story-Level Granularity)
1911
+ # Verify acceptance criteria BEFORE marking step as completed
1912
+ if step.metadata and step.metadata.get("story_id"):
1913
+ self._handle_story_completion(step, artifacts, step_execution)
1914
+
1915
+ # Update step execution (after story verification)
1916
+ step_execution.completed_at = datetime.now()
1917
+ step_execution.status = "completed"
1918
+ step_execution.result = result
1919
+
1920
+ # Remove the worktree on success (keep on failure for debugging)
1921
+ try:
1922
+ # Determine if we should delete the branch based on configuration
1923
+ from ..core.config import load_config
1924
+ config = load_config()
1925
+ should_delete = (
1926
+ config.workflow.branch_cleanup.delete_branches_on_cleanup
1927
+ if (
1928
+ config.workflow.branch_cleanup
1929
+ and config.workflow.branch_cleanup.enabled
1930
+ )
1931
+ else True # Default to True for backward compatibility
1932
+ )
1933
+ await self.worktree_manager.remove_worktree(
1934
+ worktree_name, delete_branch=should_delete
1935
+ )
1936
+ except Exception:
1937
+ pass
1938
+
1939
+ # Advance to next step
1940
+ self._advance_step()
1941
+
1942
+ except Exception as e:
1943
+ step_execution.completed_at = datetime.now()
1944
+ step_execution.status = "failed"
1945
+ step_execution.error = str(e)
1946
+ raise
1947
+
1948
+ finally:
1949
+ self.save_state()
1950
+
1951
+ def _can_execute_step(
1952
+ self,
1953
+ step: WorkflowStep,
1954
+ completed_steps: dict[str, StepResult]
1955
+ ) -> tuple[bool, str]:
1956
+ """
1957
+ Check if step can execute based on dependencies (BUG-003B fix).
1958
+
1959
+ Validates that all required dependencies have been executed and succeeded.
1960
+ If any dependency is missing or failed, the step cannot execute.
1961
+
1962
+ Args:
1963
+ step: Step to check
1964
+ completed_steps: Results of previously executed steps
1965
+
1966
+ Returns:
1967
+ (can_execute, skip_reason) tuple:
1968
+ - (True, "") if all dependencies met
1969
+ - (False, reason) if dependencies not met
1970
+
1971
+ Example:
1972
+ can_run, reason = self._can_execute_step(step, completed_steps)
1973
+ if not can_run:
1974
+ # Skip step with reason
1975
+ skip_result = StepResult(status="skipped", skip_reason=reason, ...)
1976
+ """
1977
+ for dep in step.requires or []:
1978
+ if dep not in completed_steps:
1979
+ return False, f"Dependency '{dep}' not executed"
1980
+
1981
+ dep_result = completed_steps[dep]
1982
+ if not dep_result.success:
1983
+ return False, f"Dependency '{dep}' failed: {dep_result.error}"
1984
+
1985
+ return True, ""
1986
+
1987
+ def _normalize_action(self, action: str) -> str:
1988
+ """
1989
+ Normalize action name to use underscores (Python convention).
1990
+
1991
+ Converts hyphens to underscores so workflow YAMLs can use either format,
1992
+ but handlers always receive underscore format (e.g., "write_code").
1993
+ """
1994
+ return action.replace("-", "_").lower()
1995
+
1996
+ def _get_step_params(self, step: WorkflowStep, target_path: Path | None) -> dict[str, Any]:
1997
+ """
1998
+ Extract parameters for step execution.
1999
+
2000
+ Args:
2001
+ step: Workflow step
2002
+ target_path: Optional target file path
2003
+
2004
+ Returns:
2005
+ Dictionary of parameters for command building
2006
+ """
2007
+ params: dict[str, Any] = {}
2008
+
2009
+ # Add target file if provided
2010
+ if target_path:
2011
+ try:
2012
+ # Try relative path first (most common case)
2013
+ resolved_target = Path(target_path).resolve()
2014
+ resolved_root = self.project_root.resolve()
2015
+
2016
+ # Use is_relative_to if available (Python 3.9+)
2017
+ try:
2018
+ if resolved_target.is_relative_to(resolved_root):
2019
+ params["target_file"] = str(resolved_target.relative_to(resolved_root))
2020
+ else:
2021
+ # Path is outside project root - use path normalizer
2022
+ from ...core.path_normalizer import normalize_for_cli
2023
+ params["target_file"] = normalize_for_cli(target_path, self.project_root)
2024
+ except AttributeError:
2025
+ # Python < 3.9 - use try/except
2026
+ try:
2027
+ params["target_file"] = str(resolved_target.relative_to(resolved_root))
2028
+ except ValueError:
2029
+ # Path is outside project root - use path normalizer
2030
+ from ...core.path_normalizer import normalize_for_cli
2031
+ params["target_file"] = normalize_for_cli(target_path, self.project_root)
2032
+ except Exception as e:
2033
+ # Fallback: use path normalizer for any error
2034
+ from ...core.path_normalizer import normalize_for_cli
2035
+ if self.logger:
2036
+ self.logger.warning(f"Path conversion error: {e}. Using path normalizer.")
2037
+ params["target_file"] = normalize_for_cli(target_path, self.project_root)
2038
+
2039
+ # Add step metadata
2040
+ if step.metadata:
2041
+ params.update(step.metadata)
2042
+
2043
+ # Add workflow variables
2044
+ if self.state and self.state.variables:
2045
+ # Include relevant variables (avoid exposing everything)
2046
+ if "user_prompt" in self.state.variables:
2047
+ params["user_prompt"] = self.state.variables["user_prompt"]
2048
+ if "target_file" in self.state.variables:
2049
+ params["target_file"] = self.state.variables["target_file"]
2050
+
2051
+ return params
2052
+
2053
+ def _handle_story_completion(
2054
+ self, step: WorkflowStep, artifacts: list[Artifact], step_execution: StepExecution
2055
+ ) -> None:
2056
+ """
2057
+ Handle story-level step completion (Phase 3: Story-Level Granularity).
2058
+
2059
+ Verifies acceptance criteria, logs to progress.txt, and tracks story completion.
2060
+
2061
+ Args:
2062
+ step: Completed workflow step with story metadata
2063
+ artifacts: Artifacts created by the step
2064
+ step_execution: Step execution record to update if criteria fail
2065
+ """
2066
+ if not step.metadata:
2067
+ return
2068
+
2069
+ story_id = step.metadata.get("story_id")
2070
+ story_title = step.metadata.get("story_title")
2071
+ acceptance_criteria = step.metadata.get("acceptance_criteria", [])
2072
+
2073
+ if not story_id:
2074
+ return # Not a story-level step
2075
+
2076
+ # Verify acceptance criteria if provided
2077
+ passes = True
2078
+ verification_result = None
2079
+
2080
+ if acceptance_criteria:
2081
+ from .acceptance_verifier import AcceptanceCriteriaVerifier
2082
+
2083
+ # Convert artifacts list to dict
2084
+ artifacts_dict = {art.name: art for art in artifacts}
2085
+
2086
+ # Get code files from artifacts
2087
+ code_files = []
2088
+ for art in artifacts:
2089
+ if art.path:
2090
+ art_path = Path(art.path)
2091
+ if art_path.exists() and art_path.suffix in [".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".go", ".rs"]:
2092
+ code_files.append(art_path)
2093
+
2094
+ # Verify criteria
2095
+ verifier = AcceptanceCriteriaVerifier()
2096
+ verification_result = verifier.verify(
2097
+ criteria=acceptance_criteria,
2098
+ artifacts=artifacts_dict,
2099
+ code_files=code_files if code_files else None,
2100
+ )
2101
+ passes = verification_result.get("all_passed", True)
2102
+
2103
+ # Store verification result in state variables
2104
+ if "story_verifications" not in self.state.variables:
2105
+ self.state.variables["story_verifications"] = {}
2106
+ self.state.variables["story_verifications"][story_id] = verification_result
2107
+
2108
+ # Track story completion in state.variables
2109
+ if "story_completions" not in self.state.variables:
2110
+ self.state.variables["story_completions"] = {}
2111
+ self.state.variables["story_completions"][story_id] = passes
2112
+
2113
+ # Log to progress.txt if progress logger is available
2114
+ try:
2115
+ from .progress_logger import ProgressLogger
2116
+
2117
+ progress_file = self.project_root / ".tapps-agents" / "progress.txt"
2118
+ progress_logger = ProgressLogger(progress_file)
2119
+
2120
+ # Extract files changed
2121
+ files_changed = [art.path for art in artifacts if art.path]
2122
+
2123
+ # Extract learnings from verification result
2124
+ learnings = []
2125
+ if verification_result and not passes:
2126
+ failed_criteria = [
2127
+ r["criterion"]
2128
+ for r in verification_result.get("results", [])
2129
+ if not r.get("passed", False)
2130
+ ]
2131
+ if failed_criteria:
2132
+ learnings.append(f"Acceptance criteria not met: {', '.join(failed_criteria)}")
2133
+
2134
+ # Log story completion
2135
+ progress_logger.log_story_completion(
2136
+ story_id=story_id,
2137
+ story_title=story_title or step.id,
2138
+ passes=passes,
2139
+ files_changed=files_changed if files_changed else None,
2140
+ learnings=learnings if learnings else None,
2141
+ )
2142
+ except Exception:
2143
+ # Don't fail workflow if progress logging fails
2144
+ import logging
2145
+ logger = logging.getLogger(__name__)
2146
+ logger.warning("Failed to log story completion to progress.txt", exc_info=True)
2147
+
2148
+ # If acceptance criteria not met, mark step as failed and raise exception
2149
+ if not passes:
2150
+ step_execution.status = "failed"
2151
+ step_execution.error = f"Acceptance criteria not met for story {story_id}"
2152
+ # Raise exception to prevent advancing to next step
2153
+ raise ValueError(f"Story {story_id} failed acceptance criteria verification")
2154
+
2155
+ def _advance_step(self) -> None:
2156
+ """Advance to the next workflow step."""
2157
+ if not self.workflow or not self.state:
2158
+ return
2159
+
2160
+ # Use auto-progression if enabled
2161
+ if self.auto_progression.should_auto_progress():
2162
+ current_step = self.get_current_step()
2163
+ if current_step:
2164
+ # Get progression decision
2165
+ step_execution = next(
2166
+ (se for se in self.state.step_executions if se.step_id == current_step.id),
2167
+ None
2168
+ )
2169
+ if step_execution:
2170
+ review_result = None
2171
+ if current_step.agent == "reviewer":
2172
+ review_result = self.state.variables.get("reviewer_result")
2173
+
2174
+ decision = self.auto_progression.handle_step_completion(
2175
+ step=current_step,
2176
+ state=self.state,
2177
+ step_execution=step_execution,
2178
+ review_result=review_result,
2179
+ )
2180
+
2181
+ next_step_id = self.auto_progression.get_next_step_id(
2182
+ step=current_step,
2183
+ decision=decision,
2184
+ workflow_steps=self.workflow.steps,
2185
+ )
2186
+
2187
+ if next_step_id:
2188
+ self.state.current_step = next_step_id
2189
+ else:
2190
+ # Workflow complete
2191
+ self.state.status = "completed"
2192
+ self.state.completed_at = datetime.now()
2193
+ self.state.current_step = None
2194
+ return
2195
+
2196
+ # Fallback to sequential progression
2197
+ current_index = None
2198
+ for i, step in enumerate(self.workflow.steps):
2199
+ if step.id == self.state.current_step:
2200
+ current_index = i
2201
+ break
2202
+
2203
+ if current_index is None:
2204
+ self.state.status = "failed"
2205
+ self.state.error = f"Current step {self.state.current_step} not found"
2206
+ return
2207
+
2208
+ # Move to next step
2209
+ if current_index + 1 < len(self.workflow.steps):
2210
+ self.state.current_step = self.workflow.steps[current_index + 1].id
2211
+ else:
2212
+ # All steps completed
2213
+ self.state.status = "completed"
2214
+ self.state.completed_at = datetime.now()
2215
+ self.state.current_step = None
2216
+
2217
+ def get_progression_status(self) -> dict[str, Any]:
2218
+ """
2219
+ Get current progression status and visibility information.
2220
+
2221
+ Returns:
2222
+ Dictionary with progression status
2223
+ """
2224
+ if not self.workflow or not self.state:
2225
+ return {"status": "not_started"}
2226
+
2227
+ return self.auto_progression.get_progression_status(
2228
+ state=self.state,
2229
+ workflow_steps=self.workflow.steps,
2230
+ )
2231
+
2232
+ def get_progression_history(self, step_id: str | None = None) -> list[dict[str, Any]]:
2233
+ """
2234
+ Get progression history.
2235
+
2236
+ Args:
2237
+ step_id: Optional step ID to filter by
2238
+
2239
+ Returns:
2240
+ List of progression history entries
2241
+ """
2242
+ history = self.auto_progression.get_progression_history(step_id=step_id)
2243
+ return [
2244
+ {
2245
+ "step_id": h.step_id,
2246
+ "timestamp": h.timestamp.isoformat(),
2247
+ "action": h.action.value,
2248
+ "reason": h.reason,
2249
+ "gate_result": h.gate_result,
2250
+ "metadata": h.metadata,
2251
+ }
2252
+ for h in history
2253
+ ]
2254
+
2255
+ def pause_workflow(self) -> None:
2256
+ """
2257
+ Pause workflow execution.
2258
+
2259
+ Epic 10: Progression Control
2260
+ """
2261
+ if not self.state:
2262
+ raise ValueError("Workflow not started")
2263
+
2264
+ if self.state.status == "running":
2265
+ self.state.status = "paused"
2266
+ self.save_state()
2267
+ if self.logger:
2268
+ self.logger.info("Workflow paused by user")
2269
+ self.auto_progression.record_progression(
2270
+ step_id=self.state.current_step or "unknown",
2271
+ action=ProgressionAction.PAUSE,
2272
+ reason="Workflow paused by user",
2273
+ )
2274
+
2275
+ def resume_workflow(self) -> None:
2276
+ """
2277
+ Resume paused workflow execution.
2278
+
2279
+ Epic 10: Progression Control
2280
+ """
2281
+ if not self.state:
2282
+ raise ValueError("Workflow not started")
2283
+
2284
+ if self.state.status == "paused":
2285
+ self.state.status = "running"
2286
+ self.save_state()
2287
+ if self.logger:
2288
+ self.logger.info("Workflow resumed by user")
2289
+ self.auto_progression.record_progression(
2290
+ step_id=self.state.current_step or "unknown",
2291
+ action=ProgressionAction.CONTINUE,
2292
+ reason="Workflow resumed by user",
2293
+ )
2294
+
2295
+ def skip_step(self, step_id: str | None = None) -> None:
2296
+ """
2297
+ Skip a workflow step.
2298
+
2299
+ Args:
2300
+ step_id: Step ID to skip (defaults to current step)
2301
+
2302
+ Epic 10: Progression Control
2303
+ """
2304
+ if not self.state or not self.workflow:
2305
+ raise ValueError("Workflow not started")
2306
+
2307
+ step_id = step_id or self.state.current_step
2308
+ if not step_id:
2309
+ raise ValueError("No step to skip")
2310
+
2311
+ # Find the step
2312
+ step = next((s for s in self.workflow.steps if s.id == step_id), None)
2313
+ if not step:
2314
+ raise ValueError(f"Step {step_id} not found")
2315
+
2316
+ # Record skip in progression history
2317
+ self.auto_progression.record_progression(
2318
+ step_id=step_id,
2319
+ action=ProgressionAction.SKIP,
2320
+ reason="Step skipped by user",
2321
+ )
2322
+
2323
+ # Advance to next step
2324
+ if step.next:
2325
+ self.state.current_step = step.next
2326
+ self.save_state()
2327
+ if self.logger:
2328
+ self.logger.info(f"Step {step_id} skipped, advancing to {step.next}")
2329
+ else:
2330
+ # No next step - workflow complete
2331
+ self.state.status = "completed"
2332
+ self.state.completed_at = datetime.now()
2333
+ self.state.current_step = None
2334
+ self.save_state()
2335
+ if self.logger:
2336
+ self.logger.info(f"Step {step_id} skipped, workflow completed")
2337
+