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