ouroboros-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ouroboros-ai might be problematic. Click here for more details.

Files changed (81) hide show
  1. ouroboros/__init__.py +15 -0
  2. ouroboros/__main__.py +9 -0
  3. ouroboros/bigbang/__init__.py +39 -0
  4. ouroboros/bigbang/ambiguity.py +464 -0
  5. ouroboros/bigbang/interview.py +530 -0
  6. ouroboros/bigbang/seed_generator.py +610 -0
  7. ouroboros/cli/__init__.py +9 -0
  8. ouroboros/cli/commands/__init__.py +7 -0
  9. ouroboros/cli/commands/config.py +79 -0
  10. ouroboros/cli/commands/init.py +425 -0
  11. ouroboros/cli/commands/run.py +201 -0
  12. ouroboros/cli/commands/status.py +85 -0
  13. ouroboros/cli/formatters/__init__.py +31 -0
  14. ouroboros/cli/formatters/panels.py +157 -0
  15. ouroboros/cli/formatters/progress.py +112 -0
  16. ouroboros/cli/formatters/tables.py +166 -0
  17. ouroboros/cli/main.py +60 -0
  18. ouroboros/config/__init__.py +81 -0
  19. ouroboros/config/loader.py +292 -0
  20. ouroboros/config/models.py +332 -0
  21. ouroboros/core/__init__.py +62 -0
  22. ouroboros/core/ac_tree.py +401 -0
  23. ouroboros/core/context.py +472 -0
  24. ouroboros/core/errors.py +246 -0
  25. ouroboros/core/seed.py +212 -0
  26. ouroboros/core/types.py +205 -0
  27. ouroboros/evaluation/__init__.py +110 -0
  28. ouroboros/evaluation/consensus.py +350 -0
  29. ouroboros/evaluation/mechanical.py +351 -0
  30. ouroboros/evaluation/models.py +235 -0
  31. ouroboros/evaluation/pipeline.py +286 -0
  32. ouroboros/evaluation/semantic.py +302 -0
  33. ouroboros/evaluation/trigger.py +278 -0
  34. ouroboros/events/__init__.py +5 -0
  35. ouroboros/events/base.py +80 -0
  36. ouroboros/events/decomposition.py +153 -0
  37. ouroboros/events/evaluation.py +248 -0
  38. ouroboros/execution/__init__.py +44 -0
  39. ouroboros/execution/atomicity.py +451 -0
  40. ouroboros/execution/decomposition.py +481 -0
  41. ouroboros/execution/double_diamond.py +1386 -0
  42. ouroboros/execution/subagent.py +275 -0
  43. ouroboros/observability/__init__.py +63 -0
  44. ouroboros/observability/drift.py +383 -0
  45. ouroboros/observability/logging.py +504 -0
  46. ouroboros/observability/retrospective.py +338 -0
  47. ouroboros/orchestrator/__init__.py +78 -0
  48. ouroboros/orchestrator/adapter.py +391 -0
  49. ouroboros/orchestrator/events.py +278 -0
  50. ouroboros/orchestrator/runner.py +597 -0
  51. ouroboros/orchestrator/session.py +486 -0
  52. ouroboros/persistence/__init__.py +23 -0
  53. ouroboros/persistence/checkpoint.py +511 -0
  54. ouroboros/persistence/event_store.py +183 -0
  55. ouroboros/persistence/migrations/__init__.py +1 -0
  56. ouroboros/persistence/migrations/runner.py +100 -0
  57. ouroboros/persistence/migrations/scripts/001_initial.sql +20 -0
  58. ouroboros/persistence/schema.py +56 -0
  59. ouroboros/persistence/uow.py +230 -0
  60. ouroboros/providers/__init__.py +28 -0
  61. ouroboros/providers/base.py +133 -0
  62. ouroboros/providers/claude_code_adapter.py +212 -0
  63. ouroboros/providers/litellm_adapter.py +316 -0
  64. ouroboros/py.typed +0 -0
  65. ouroboros/resilience/__init__.py +67 -0
  66. ouroboros/resilience/lateral.py +595 -0
  67. ouroboros/resilience/stagnation.py +727 -0
  68. ouroboros/routing/__init__.py +60 -0
  69. ouroboros/routing/complexity.py +272 -0
  70. ouroboros/routing/downgrade.py +664 -0
  71. ouroboros/routing/escalation.py +340 -0
  72. ouroboros/routing/router.py +204 -0
  73. ouroboros/routing/tiers.py +247 -0
  74. ouroboros/secondary/__init__.py +40 -0
  75. ouroboros/secondary/scheduler.py +467 -0
  76. ouroboros/secondary/todo_registry.py +483 -0
  77. ouroboros_ai-0.1.0.dist-info/METADATA +607 -0
  78. ouroboros_ai-0.1.0.dist-info/RECORD +81 -0
  79. ouroboros_ai-0.1.0.dist-info/WHEEL +4 -0
  80. ouroboros_ai-0.1.0.dist-info/entry_points.txt +2 -0
  81. ouroboros_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1386 @@
1
+ """Double Diamond cycle implementation for Phase 2 Execution.
2
+
3
+ The Double Diamond is a design thinking pattern with four phases:
4
+ 1. Discover (Diverge): Explore problem space, gather insights
5
+ 2. Define (Converge): Converge on approach, filter through ontology
6
+ 3. Design (Diverge): Create solution options
7
+ 4. Deliver (Converge): Implement and validate, filter through ontology
8
+
9
+ This module implements:
10
+ - Phase enum with phase metadata (divergent/convergent, ordering)
11
+ - PhaseContext for passing state between phases
12
+ - PhaseResult for capturing phase outputs
13
+ - DoubleDiamond class orchestrating the full cycle
14
+ - Retry logic with exponential backoff for phase failures
15
+ - Event emission for phase transitions and cycle lifecycle
16
+
17
+ Usage:
18
+ from ouroboros.execution.double_diamond import DoubleDiamond
19
+
20
+ dd = DoubleDiamond(llm_adapter=adapter)
21
+ result = await dd.run_cycle(
22
+ execution_id="exec-123",
23
+ seed_id="seed-456",
24
+ current_ac="Implement user authentication",
25
+ iteration=1,
26
+ )
27
+ if result.is_ok:
28
+ cycle_result = result.value
29
+ print(f"Cycle completed: {cycle_result.success}")
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ from dataclasses import dataclass, field
36
+ from enum import Enum
37
+ from typing import TYPE_CHECKING, Any
38
+
39
+ from ouroboros.core.errors import OuroborosError, ProviderError
40
+ from ouroboros.core.types import Result
41
+ from ouroboros.events.base import BaseEvent
42
+ from ouroboros.observability.logging import get_logger
43
+ from ouroboros.resilience.stagnation import (
44
+ ExecutionHistory,
45
+ StagnationDetector,
46
+ create_stagnation_event,
47
+ )
48
+
49
+ if TYPE_CHECKING:
50
+ from ouroboros.providers.litellm_adapter import LiteLLMAdapter
51
+
52
+ log = get_logger(__name__)
53
+
54
+
55
+ # =============================================================================
56
+ # Phase-specific prompts (extracted for maintainability)
57
+ # =============================================================================
58
+
59
+ PHASE_PROMPTS: dict[str, dict[str, str]] = {
60
+ "discover": {
61
+ "system": """You are an expert problem analyst in the Discover phase of the Double Diamond process.
62
+ Your role is to DIVERGE - explore the problem space widely and gather insights.
63
+
64
+ Guidelines:
65
+ - Ask clarifying questions about requirements
66
+ - Identify potential challenges and risks
67
+ - Explore different perspectives on the problem
68
+ - List assumptions that need validation
69
+ - Note any ambiguities or unknowns""",
70
+ "user_template": """Acceptance Criterion: {current_ac}
71
+
72
+ Execution ID: {execution_id}
73
+ Iteration: {iteration}
74
+
75
+ Explore this problem space. What insights, questions, challenges, and considerations emerge?""",
76
+ "output_key": "insights",
77
+ "event_data_key": "insights_generated",
78
+ },
79
+ "define": {
80
+ "system": """You are an expert analyst in the Define phase of the Double Diamond process.
81
+ Your role is to CONVERGE - narrow down and define the approach based on insights gathered.
82
+
83
+ Guidelines:
84
+ - Synthesize insights from the Discover phase
85
+ - Define clear requirements and constraints
86
+ - Prioritize what's most important
87
+ - Make decisions on approach
88
+ - Apply ontology filter: ensure alignment with domain concepts""",
89
+ "user_template": """Acceptance Criterion: {current_ac}
90
+
91
+ Discover Phase Output:
92
+ {previous_output}
93
+
94
+ Based on the insights gathered, define the approach. What requirements, constraints, and priorities emerge?""",
95
+ "output_key": "approach",
96
+ "event_data_key": "approach_defined",
97
+ "previous_phase": "discover",
98
+ },
99
+ "design": {
100
+ "system": """You are an expert solution architect in the Design phase of the Double Diamond process.
101
+ Your role is to DIVERGE - create multiple solution options and explore possibilities.
102
+
103
+ Guidelines:
104
+ - Generate multiple solution approaches
105
+ - Consider trade-offs for each approach
106
+ - Be creative and explore alternatives
107
+ - Include both conventional and innovative solutions
108
+ - Document assumptions for each approach""",
109
+ "user_template": """Acceptance Criterion: {current_ac}
110
+
111
+ Define Phase Output:
112
+ {previous_output}
113
+
114
+ Design solution options. What approaches can address the defined requirements?""",
115
+ "output_key": "solution",
116
+ "event_data_key": "solutions_designed",
117
+ "previous_phase": "define",
118
+ },
119
+ "deliver": {
120
+ "system": """You are an expert implementer in the Deliver phase of the Double Diamond process.
121
+ Your role is to CONVERGE - select the best solution and implement it.
122
+
123
+ Guidelines:
124
+ - Select the most appropriate solution from Design phase
125
+ - Provide concrete implementation details
126
+ - Validate the solution meets acceptance criteria
127
+ - Apply ontology filter: ensure alignment with domain concepts
128
+ - Document what was implemented and why""",
129
+ "user_template": """Acceptance Criterion: {current_ac}
130
+
131
+ Design Phase Output:
132
+ {previous_output}
133
+
134
+ Deliver the solution. Select the best approach and provide implementation details.""",
135
+ "output_key": "result",
136
+ "event_data_key": "delivery_completed",
137
+ "previous_phase": "design",
138
+ },
139
+ }
140
+
141
+
142
+ # =============================================================================
143
+ # Phase Enum
144
+ # =============================================================================
145
+
146
+
147
+ class Phase(str, Enum):
148
+ """Double Diamond phase enumeration.
149
+
150
+ Four phases with alternating diverge/converge pattern:
151
+ - DISCOVER: Diverge - explore problem space
152
+ - DEFINE: Converge - narrow down approach (ontology filter active)
153
+ - DESIGN: Diverge - create solution options
154
+ - DELIVER: Converge - implement and validate (ontology filter active)
155
+
156
+ Attributes:
157
+ is_divergent: True for Discover and Design phases
158
+ is_convergent: True for Define and Deliver phases
159
+ next_phase: The next phase in sequence (None for DELIVER)
160
+ order: Numeric ordering for sorting (0-3)
161
+ """
162
+
163
+ DISCOVER = "discover"
164
+ DEFINE = "define"
165
+ DESIGN = "design"
166
+ DELIVER = "deliver"
167
+
168
+ @property
169
+ def is_divergent(self) -> bool:
170
+ """Return True if this is a divergent phase (Discover, Design)."""
171
+ return self in (Phase.DISCOVER, Phase.DESIGN)
172
+
173
+ @property
174
+ def is_convergent(self) -> bool:
175
+ """Return True if this is a convergent phase (Define, Deliver)."""
176
+ return self in (Phase.DEFINE, Phase.DELIVER)
177
+
178
+ @property
179
+ def next_phase(self) -> Phase | None:
180
+ """Return the next phase in sequence, or None if this is DELIVER."""
181
+ sequence = {
182
+ Phase.DISCOVER: Phase.DEFINE,
183
+ Phase.DEFINE: Phase.DESIGN,
184
+ Phase.DESIGN: Phase.DELIVER,
185
+ Phase.DELIVER: None,
186
+ }
187
+ return sequence[self]
188
+
189
+ @property
190
+ def order(self) -> int:
191
+ """Return numeric order for sorting (0-3)."""
192
+ ordering = {
193
+ Phase.DISCOVER: 0,
194
+ Phase.DEFINE: 1,
195
+ Phase.DESIGN: 2,
196
+ Phase.DELIVER: 3,
197
+ }
198
+ return ordering[self]
199
+
200
+
201
+ # =============================================================================
202
+ # Data Models (frozen for immutability)
203
+ # =============================================================================
204
+
205
+
206
+ @dataclass(frozen=True, slots=True)
207
+ class PhaseResult:
208
+ """Result of executing a single phase.
209
+
210
+ Attributes:
211
+ phase: The phase that was executed.
212
+ success: Whether the phase completed successfully.
213
+ output: Phase-specific output data.
214
+ events: Events emitted during phase execution.
215
+ error_message: Error message if phase failed.
216
+ """
217
+
218
+ phase: Phase
219
+ success: bool
220
+ output: dict[str, Any]
221
+ events: list[BaseEvent]
222
+ error_message: str | None = None
223
+
224
+
225
+ @dataclass(frozen=True, slots=True)
226
+ class PhaseContext:
227
+ """Context for phase execution.
228
+
229
+ Contains all state needed to execute a phase, including results
230
+ from previous phases in the current cycle.
231
+
232
+ Attributes:
233
+ execution_id: Unique identifier for this execution.
234
+ seed_id: Identifier of the seed being executed.
235
+ current_ac: The acceptance criterion being worked on.
236
+ phase: The current phase being executed.
237
+ iteration: Current iteration number.
238
+ previous_results: Results from phases completed so far in this cycle.
239
+ Note: While the dataclass is frozen, the dict contents are mutable.
240
+ Callers should not modify this dict after construction.
241
+ depth: Current depth in AC decomposition tree (0 = root).
242
+ parent_ac: Parent AC content if this is a child AC.
243
+ """
244
+
245
+ execution_id: str
246
+ seed_id: str
247
+ current_ac: str
248
+ phase: Phase
249
+ iteration: int
250
+ previous_results: dict[Phase, PhaseResult] = field(default_factory=dict)
251
+ depth: int = 0
252
+ parent_ac: str | None = None
253
+
254
+
255
+ @dataclass(frozen=True, slots=True)
256
+ class CycleResult:
257
+ """Result of a complete Double Diamond cycle.
258
+
259
+ Aggregates results from all four phases.
260
+
261
+ Attributes:
262
+ execution_id: Unique identifier for this execution.
263
+ seed_id: Identifier of the seed being executed.
264
+ current_ac: The acceptance criterion that was worked on.
265
+ success: Whether the full cycle completed successfully.
266
+ phase_results: Results from each phase.
267
+ events: All events emitted during the cycle (including cycle-level events).
268
+ is_decomposed: Whether this AC was decomposed into children.
269
+ child_results: Results from child AC cycles (if decomposed).
270
+ depth: Depth at which this cycle was executed.
271
+ """
272
+
273
+ execution_id: str
274
+ seed_id: str
275
+ current_ac: str
276
+ success: bool
277
+ phase_results: dict[Phase, PhaseResult]
278
+ events: list[BaseEvent]
279
+ is_decomposed: bool = False
280
+ child_results: tuple["CycleResult", ...] = field(default_factory=tuple)
281
+ depth: int = 0
282
+
283
+ @property
284
+ def final_output(self) -> dict[str, Any]:
285
+ """Return the output from the DELIVER phase (final result)."""
286
+ if Phase.DELIVER in self.phase_results:
287
+ return self.phase_results[Phase.DELIVER].output
288
+ return {}
289
+
290
+ @property
291
+ def all_events(self) -> list[BaseEvent]:
292
+ """Return all events including from child cycles."""
293
+ events = list(self.events)
294
+ for child in self.child_results:
295
+ events.extend(child.all_events)
296
+ return events
297
+
298
+
299
+ # =============================================================================
300
+ # Errors
301
+ # =============================================================================
302
+
303
+
304
+ class ExecutionError(OuroborosError):
305
+ """Error during Double Diamond execution.
306
+
307
+ Attributes:
308
+ phase: The phase that failed.
309
+ attempt: The retry attempt number.
310
+ """
311
+
312
+ def __init__(
313
+ self,
314
+ message: str,
315
+ *,
316
+ phase: Phase | None = None,
317
+ attempt: int = 0,
318
+ details: dict[str, Any] | None = None,
319
+ ) -> None:
320
+ super().__init__(message, details)
321
+ self.phase = phase
322
+ self.attempt = attempt
323
+
324
+
325
+ # =============================================================================
326
+ # DoubleDiamond Orchestrator
327
+ # =============================================================================
328
+
329
+
330
+ class DoubleDiamond:
331
+ """Orchestrator for the Double Diamond execution cycle.
332
+
333
+ Manages the four-phase cycle with retry logic and event emission.
334
+
335
+ Attributes:
336
+ llm_adapter: LLM adapter for calling language models.
337
+ default_model: Default model for LLM calls (can be overridden per-phase).
338
+ temperature: Temperature setting for LLM calls.
339
+ max_tokens: Maximum tokens for LLM responses.
340
+ max_retries: Maximum retry attempts per phase (default: 3).
341
+ base_delay: Base delay in seconds for exponential backoff (default: 2.0).
342
+ """
343
+
344
+ # Default model - can be overridden via __init__ for PAL router integration
345
+ DEFAULT_MODEL = "openrouter/google/gemini-2.0-flash-001"
346
+ DEFAULT_TEMPERATURE = 0.7
347
+ DEFAULT_MAX_TOKENS = 4096
348
+
349
+ def __init__(
350
+ self,
351
+ llm_adapter: LiteLLMAdapter,
352
+ *,
353
+ default_model: str | None = None,
354
+ temperature: float | None = None,
355
+ max_tokens: int | None = None,
356
+ max_retries: int = 3,
357
+ base_delay: float = 2.0,
358
+ enable_stagnation_detection: bool = True,
359
+ ) -> None:
360
+ """Initialize DoubleDiamond.
361
+
362
+ Args:
363
+ llm_adapter: LLM adapter for calling language models.
364
+ default_model: Model identifier for LLM calls. Defaults to Gemini Flash.
365
+ Can be overridden for PAL router tier integration.
366
+ temperature: Temperature for LLM sampling. Defaults to 0.7.
367
+ max_tokens: Maximum tokens for LLM responses. Defaults to 4096.
368
+ max_retries: Maximum retry attempts per phase.
369
+ base_delay: Base delay in seconds for exponential backoff.
370
+ enable_stagnation_detection: Enable stagnation pattern detection.
371
+ """
372
+ self._llm_adapter = llm_adapter
373
+ self._default_model = default_model or self.DEFAULT_MODEL
374
+ self._temperature = temperature if temperature is not None else self.DEFAULT_TEMPERATURE
375
+ self._max_tokens = max_tokens if max_tokens is not None else self.DEFAULT_MAX_TOKENS
376
+ self._max_retries = max_retries
377
+ self._base_delay = base_delay
378
+ self._enable_stagnation_detection = enable_stagnation_detection
379
+ self._stagnation_detector = StagnationDetector() if enable_stagnation_detection else None
380
+
381
+ # Execution history for stagnation detection (per execution_id)
382
+ # Mutable state: tracks recent outputs/errors for pattern detection
383
+ self._execution_histories: dict[str, dict[str, list]] = {}
384
+
385
+ def _get_execution_history(self, execution_id: str) -> dict[str, list]:
386
+ """Get or create execution history for stagnation detection.
387
+
388
+ Args:
389
+ execution_id: Execution identifier.
390
+
391
+ Returns:
392
+ Mutable dict with 'outputs', 'errors', 'drifts' lists.
393
+ """
394
+ if execution_id not in self._execution_histories:
395
+ self._execution_histories[execution_id] = {
396
+ "outputs": [],
397
+ "errors": [],
398
+ "drifts": [],
399
+ }
400
+ return self._execution_histories[execution_id]
401
+
402
+ def _record_cycle_output(
403
+ self,
404
+ execution_id: str,
405
+ phase_results: dict[Phase, PhaseResult],
406
+ error_message: str | None = None,
407
+ ) -> None:
408
+ """Record cycle output for stagnation detection.
409
+
410
+ Args:
411
+ execution_id: Execution identifier.
412
+ phase_results: Results from completed phases.
413
+ error_message: Error message if cycle failed.
414
+ """
415
+ history = self._get_execution_history(execution_id)
416
+
417
+ # Extract output from DELIVER phase (or last completed phase)
418
+ output_text = ""
419
+ for phase in [Phase.DELIVER, Phase.DESIGN, Phase.DEFINE, Phase.DISCOVER]:
420
+ if phase in phase_results:
421
+ output_key = PHASE_PROMPTS[phase.value]["output_key"]
422
+ output_text = str(phase_results[phase].output.get(output_key, ""))
423
+ break
424
+
425
+ history["outputs"].append(output_text)
426
+
427
+ # Keep only last 10 outputs (sliding window)
428
+ if len(history["outputs"]) > 10:
429
+ history["outputs"] = history["outputs"][-10:]
430
+
431
+ if error_message:
432
+ history["errors"].append(error_message)
433
+ if len(history["errors"]) > 10:
434
+ history["errors"] = history["errors"][-10:]
435
+
436
+ def _check_stagnation(
437
+ self,
438
+ execution_id: str,
439
+ seed_id: str,
440
+ iteration: int,
441
+ ) -> list[BaseEvent]:
442
+ """Check for stagnation patterns and emit events.
443
+
444
+ Args:
445
+ execution_id: Execution identifier.
446
+ seed_id: Seed identifier.
447
+ iteration: Current iteration number.
448
+
449
+ Returns:
450
+ List of stagnation events (empty if none detected).
451
+ """
452
+ if not self._enable_stagnation_detection or not self._stagnation_detector:
453
+ return []
454
+
455
+ history_data = self._get_execution_history(execution_id)
456
+
457
+ # Build ExecutionHistory from tracked data
458
+ exec_history = ExecutionHistory.from_lists(
459
+ phase_outputs=history_data["outputs"],
460
+ error_signatures=history_data["errors"],
461
+ drift_scores=history_data["drifts"],
462
+ iteration=iteration,
463
+ )
464
+
465
+ # Run detection
466
+ result = self._stagnation_detector.detect(exec_history)
467
+
468
+ if result.is_err:
469
+ log.warning(
470
+ "execution.stagnation.detection_failed",
471
+ execution_id=execution_id,
472
+ )
473
+ return []
474
+
475
+ # Create events for detected patterns
476
+ events: list[BaseEvent] = []
477
+ for detection in result.value:
478
+ if detection.detected:
479
+ event = create_stagnation_event(
480
+ detection=detection,
481
+ execution_id=execution_id,
482
+ seed_id=seed_id,
483
+ iteration=iteration,
484
+ )
485
+ events.append(event)
486
+
487
+ log.warning(
488
+ "execution.stagnation.pattern_detected",
489
+ execution_id=execution_id,
490
+ pattern=detection.pattern.value,
491
+ confidence=detection.confidence,
492
+ iteration=iteration,
493
+ )
494
+
495
+ return events
496
+
497
+ def clear_execution_history(self, execution_id: str) -> None:
498
+ """Clear execution history for an execution.
499
+
500
+ Call this when execution completes successfully or is abandoned.
501
+
502
+ Args:
503
+ execution_id: Execution identifier to clear.
504
+ """
505
+ if execution_id in self._execution_histories:
506
+ del self._execution_histories[execution_id]
507
+
508
+ def _calculate_backoff(self, attempt: int) -> float:
509
+ """Calculate exponential backoff delay.
510
+
511
+ Args:
512
+ attempt: The current attempt number (0-indexed).
513
+
514
+ Returns:
515
+ Delay in seconds (base_delay * 2^attempt).
516
+ """
517
+ return float(self._base_delay * (2**attempt))
518
+
519
+ def _emit_event(
520
+ self,
521
+ event_type: str,
522
+ execution_id: str,
523
+ seed_id: str,
524
+ data: dict[str, Any] | None = None,
525
+ ) -> BaseEvent:
526
+ """Emit an execution-related event.
527
+
528
+ Args:
529
+ event_type: Event type (e.g., "execution.cycle.started").
530
+ execution_id: Execution identifier.
531
+ seed_id: Seed identifier.
532
+ data: Additional event data.
533
+
534
+ Returns:
535
+ The created event.
536
+ """
537
+ event_data = {"seed_id": seed_id, **(data or {})}
538
+ return BaseEvent(
539
+ type=event_type,
540
+ aggregate_type="execution",
541
+ aggregate_id=execution_id,
542
+ data=event_data,
543
+ )
544
+
545
+ def _emit_phase_event(
546
+ self,
547
+ event_type: str,
548
+ phase: Phase,
549
+ execution_id: str,
550
+ seed_id: str,
551
+ data: dict[str, Any] | None = None,
552
+ ) -> BaseEvent:
553
+ """Emit a phase-related event.
554
+
555
+ Args:
556
+ event_type: Event type (e.g., "execution.phase.started").
557
+ phase: The current phase.
558
+ execution_id: Execution identifier.
559
+ seed_id: Seed identifier.
560
+ data: Additional event data.
561
+
562
+ Returns:
563
+ The created event.
564
+ """
565
+ event_data = {"phase": phase.value, "seed_id": seed_id, **(data or {})}
566
+ return BaseEvent(
567
+ type=event_type,
568
+ aggregate_type="execution",
569
+ aggregate_id=execution_id,
570
+ data=event_data,
571
+ )
572
+
573
+ async def _execute_phase_with_retry(
574
+ self,
575
+ ctx: PhaseContext,
576
+ phase_fn: Any,
577
+ ) -> Result[PhaseResult, ExecutionError]:
578
+ """Execute a phase with retry logic.
579
+
580
+ Args:
581
+ ctx: Phase context.
582
+ phase_fn: The async phase function to execute.
583
+
584
+ Returns:
585
+ Result containing PhaseResult on success or ExecutionError on failure.
586
+ """
587
+ last_error: Exception | None = None
588
+
589
+ for attempt in range(self._max_retries):
590
+ if attempt > 0:
591
+ delay = self._calculate_backoff(attempt - 1)
592
+ log.info(
593
+ "execution.phase.retry",
594
+ phase=ctx.phase.value,
595
+ attempt=attempt + 1,
596
+ delay_seconds=delay,
597
+ execution_id=ctx.execution_id,
598
+ )
599
+ await asyncio.sleep(delay)
600
+
601
+ try:
602
+ result: Result[PhaseResult, ProviderError] = await phase_fn(ctx)
603
+ if result.is_ok:
604
+ return Result.ok(result.value)
605
+ # LLM error - will retry
606
+ last_error = result.error
607
+ log.warning(
608
+ "execution.phase.failed",
609
+ phase=ctx.phase.value,
610
+ attempt=attempt + 1,
611
+ max_retries=self._max_retries,
612
+ error=str(last_error),
613
+ execution_id=ctx.execution_id,
614
+ )
615
+ except Exception as e:
616
+ last_error = e
617
+ log.exception(
618
+ "execution.phase.exception",
619
+ phase=ctx.phase.value,
620
+ attempt=attempt + 1,
621
+ execution_id=ctx.execution_id,
622
+ )
623
+
624
+ # All retries exhausted
625
+ error_msg = f"Phase {ctx.phase.value} failed after {self._max_retries} attempts"
626
+ log.error(
627
+ "execution.phase.failed.max_retries",
628
+ phase=ctx.phase.value,
629
+ max_retries=self._max_retries,
630
+ last_error=str(last_error),
631
+ execution_id=ctx.execution_id,
632
+ )
633
+
634
+ return Result.err(
635
+ ExecutionError(
636
+ error_msg,
637
+ phase=ctx.phase,
638
+ attempt=self._max_retries,
639
+ details={"last_error": str(last_error)},
640
+ )
641
+ )
642
+
643
+ async def _call_llm(
644
+ self,
645
+ system_prompt: str,
646
+ user_prompt: str,
647
+ ) -> Result[str, ProviderError]:
648
+ """Call LLM with configured settings.
649
+
650
+ Args:
651
+ system_prompt: System prompt for the LLM.
652
+ user_prompt: User prompt for the LLM.
653
+
654
+ Returns:
655
+ Result containing LLM response content or ProviderError.
656
+ """
657
+ from ouroboros.providers.base import CompletionConfig, Message, MessageRole
658
+
659
+ messages = [
660
+ Message(role=MessageRole.SYSTEM, content=system_prompt),
661
+ Message(role=MessageRole.USER, content=user_prompt),
662
+ ]
663
+
664
+ config = CompletionConfig(
665
+ model=self._default_model,
666
+ temperature=self._temperature,
667
+ max_tokens=self._max_tokens,
668
+ )
669
+
670
+ result = await self._llm_adapter.complete(messages, config)
671
+ if result.is_ok:
672
+ return Result.ok(result.value.content)
673
+ return Result.err(result.error)
674
+
675
+ async def _execute_phase(
676
+ self,
677
+ ctx: PhaseContext,
678
+ phase: Phase,
679
+ ) -> Result[PhaseResult, ExecutionError]:
680
+ """Execute a single phase using the template pattern.
681
+
682
+ This method eliminates code duplication by using phase-specific
683
+ prompts from PHASE_PROMPTS configuration.
684
+
685
+ Args:
686
+ ctx: Phase context with execution state.
687
+ phase: The phase to execute.
688
+
689
+ Returns:
690
+ Result containing PhaseResult on success or ExecutionError on failure.
691
+ """
692
+ log.info(
693
+ "execution.phase.started",
694
+ phase=phase.value,
695
+ execution_id=ctx.execution_id,
696
+ seed_id=ctx.seed_id,
697
+ iteration=ctx.iteration,
698
+ )
699
+
700
+ prompts = PHASE_PROMPTS[phase.value]
701
+
702
+ async def _execute(c: PhaseContext) -> Result[PhaseResult, ProviderError]:
703
+ # Get previous phase output if needed
704
+ previous_output = ""
705
+ if "previous_phase" in prompts:
706
+ prev_phase = Phase(prompts["previous_phase"])
707
+ if prev_phase in c.previous_results:
708
+ previous_output = str(c.previous_results[prev_phase].output)
709
+
710
+ # Format user prompt with context
711
+ user_prompt = prompts["user_template"].format(
712
+ current_ac=c.current_ac,
713
+ execution_id=c.execution_id,
714
+ iteration=c.iteration,
715
+ previous_output=previous_output,
716
+ )
717
+
718
+ llm_result = await self._call_llm(prompts["system"], user_prompt)
719
+ if llm_result.is_err:
720
+ return Result.err(llm_result.error)
721
+
722
+ event = self._emit_phase_event(
723
+ "execution.phase.completed",
724
+ phase,
725
+ c.execution_id,
726
+ c.seed_id,
727
+ {prompts["event_data_key"]: True},
728
+ )
729
+
730
+ return Result.ok(
731
+ PhaseResult(
732
+ phase=phase,
733
+ success=True,
734
+ output={prompts["output_key"]: llm_result.value},
735
+ events=[event],
736
+ )
737
+ )
738
+
739
+ return await self._execute_phase_with_retry(ctx, _execute)
740
+
741
+ async def discover(
742
+ self, ctx: PhaseContext
743
+ ) -> Result[PhaseResult, ExecutionError]:
744
+ """Execute the Discover phase (diverge).
745
+
746
+ Explores the problem space and gathers insights.
747
+
748
+ Args:
749
+ ctx: Phase context with execution state.
750
+
751
+ Returns:
752
+ Result containing PhaseResult on success or ExecutionError on failure.
753
+ """
754
+ return await self._execute_phase(ctx, Phase.DISCOVER)
755
+
756
+ async def define(
757
+ self, ctx: PhaseContext
758
+ ) -> Result[PhaseResult, ExecutionError]:
759
+ """Execute the Define phase (converge).
760
+
761
+ Converges on approach, applying ontology filter.
762
+
763
+ Args:
764
+ ctx: Phase context with execution state.
765
+
766
+ Returns:
767
+ Result containing PhaseResult on success or ExecutionError on failure.
768
+ """
769
+ return await self._execute_phase(ctx, Phase.DEFINE)
770
+
771
+ async def design(
772
+ self, ctx: PhaseContext
773
+ ) -> Result[PhaseResult, ExecutionError]:
774
+ """Execute the Design phase (diverge).
775
+
776
+ Creates solution options.
777
+
778
+ Args:
779
+ ctx: Phase context with execution state.
780
+
781
+ Returns:
782
+ Result containing PhaseResult on success or ExecutionError on failure.
783
+ """
784
+ return await self._execute_phase(ctx, Phase.DESIGN)
785
+
786
+ async def deliver(
787
+ self, ctx: PhaseContext
788
+ ) -> Result[PhaseResult, ExecutionError]:
789
+ """Execute the Deliver phase (converge).
790
+
791
+ Implements and validates the solution.
792
+
793
+ Args:
794
+ ctx: Phase context with execution state.
795
+
796
+ Returns:
797
+ Result containing PhaseResult on success or ExecutionError on failure.
798
+ """
799
+ return await self._execute_phase(ctx, Phase.DELIVER)
800
+
801
+ async def run_cycle(
802
+ self,
803
+ execution_id: str,
804
+ seed_id: str,
805
+ current_ac: str,
806
+ iteration: int,
807
+ ) -> Result[CycleResult, ExecutionError]:
808
+ """Run a complete Double Diamond cycle.
809
+
810
+ Executes all four phases in order: Discover → Define → Design → Deliver.
811
+ Emits cycle-level events for full event sourcing traceability.
812
+
813
+ Args:
814
+ execution_id: Unique identifier for this execution.
815
+ seed_id: Identifier of the seed being executed.
816
+ current_ac: The acceptance criterion being worked on.
817
+ iteration: Current iteration number.
818
+
819
+ Returns:
820
+ Result containing CycleResult on success or ExecutionError on failure.
821
+ """
822
+ log.info(
823
+ "execution.cycle.started",
824
+ execution_id=execution_id,
825
+ seed_id=seed_id,
826
+ iteration=iteration,
827
+ )
828
+
829
+ # Emit cycle started event
830
+ cycle_started_event = self._emit_event(
831
+ "execution.cycle.started",
832
+ execution_id,
833
+ seed_id,
834
+ {"iteration": iteration, "current_ac": current_ac},
835
+ )
836
+
837
+ phase_results: dict[Phase, PhaseResult] = {}
838
+ all_events: list[BaseEvent] = [cycle_started_event]
839
+
840
+ # Execute phases in order
841
+ phase_methods = [
842
+ (Phase.DISCOVER, self.discover),
843
+ (Phase.DEFINE, self.define),
844
+ (Phase.DESIGN, self.design),
845
+ (Phase.DELIVER, self.deliver),
846
+ ]
847
+
848
+ for phase, method in phase_methods:
849
+ ctx = PhaseContext(
850
+ execution_id=execution_id,
851
+ seed_id=seed_id,
852
+ current_ac=current_ac,
853
+ phase=phase,
854
+ iteration=iteration,
855
+ previous_results=dict(phase_results), # Copy to avoid mutation
856
+ )
857
+
858
+ log.info(
859
+ "execution.phase.transition",
860
+ from_phase=list(phase_results.keys())[-1].value if phase_results else None,
861
+ to_phase=phase.value,
862
+ execution_id=execution_id,
863
+ )
864
+
865
+ result = await method(ctx)
866
+
867
+ if result.is_err:
868
+ # Record error for stagnation detection
869
+ self._record_cycle_output(
870
+ execution_id,
871
+ phase_results,
872
+ error_message=str(result.error),
873
+ )
874
+
875
+ # Check for stagnation patterns even on failure
876
+ stagnation_events = self._check_stagnation(execution_id, seed_id, iteration)
877
+ all_events.extend(stagnation_events)
878
+
879
+ # Phase failed - emit cycle failed event and return error
880
+ cycle_failed_event = self._emit_event(
881
+ "execution.cycle.failed",
882
+ execution_id,
883
+ seed_id,
884
+ {
885
+ "iteration": iteration,
886
+ "failed_phase": phase.value,
887
+ "error": str(result.error),
888
+ "stagnation_detected": len(stagnation_events) > 0,
889
+ },
890
+ )
891
+ all_events.append(cycle_failed_event)
892
+
893
+ log.error(
894
+ "execution.cycle.failed",
895
+ failed_phase=phase.value,
896
+ execution_id=execution_id,
897
+ error=str(result.error),
898
+ stagnation_patterns_detected=len(stagnation_events),
899
+ )
900
+ return Result.err(result.error)
901
+
902
+ phase_result = result.value
903
+ phase_results[phase] = phase_result
904
+ all_events.extend(phase_result.events)
905
+
906
+ # Record output for stagnation detection
907
+ self._record_cycle_output(execution_id, phase_results)
908
+
909
+ # Check for stagnation patterns (Story 4.1)
910
+ stagnation_events = self._check_stagnation(execution_id, seed_id, iteration)
911
+ all_events.extend(stagnation_events)
912
+
913
+ # Emit cycle completed event
914
+ cycle_completed_event = self._emit_event(
915
+ "execution.cycle.completed",
916
+ execution_id,
917
+ seed_id,
918
+ {
919
+ "iteration": iteration,
920
+ "phases_completed": len(phase_results),
921
+ "stagnation_detected": len(stagnation_events) > 0,
922
+ },
923
+ )
924
+ all_events.append(cycle_completed_event)
925
+
926
+ log.info(
927
+ "execution.cycle.completed",
928
+ execution_id=execution_id,
929
+ seed_id=seed_id,
930
+ iteration=iteration,
931
+ phases_completed=len(phase_results),
932
+ stagnation_patterns_detected=len(stagnation_events),
933
+ )
934
+
935
+ return Result.ok(
936
+ CycleResult(
937
+ execution_id=execution_id,
938
+ seed_id=seed_id,
939
+ current_ac=current_ac,
940
+ success=True,
941
+ phase_results=phase_results,
942
+ events=all_events,
943
+ )
944
+ )
945
+
946
+ async def run_cycle_with_decomposition(
947
+ self,
948
+ execution_id: str,
949
+ seed_id: str,
950
+ current_ac: str,
951
+ iteration: int,
952
+ *,
953
+ depth: int = 0,
954
+ max_depth: int = 5,
955
+ parent_ac: str | None = None,
956
+ ) -> Result[CycleResult, ExecutionError]:
957
+ """Run Double Diamond cycle with hierarchical AC decomposition.
958
+
959
+ This method extends run_cycle to support:
960
+ - Atomicity detection at Define phase
961
+ - Recursive decomposition up to max_depth
962
+ - Context compression at depth 3+
963
+
964
+ If the AC is atomic, executes the full cycle (Discover → Define → Design → Deliver).
965
+ If non-atomic, decomposes after Define and recursively processes children.
966
+
967
+ Args:
968
+ execution_id: Unique identifier for this execution.
969
+ seed_id: Identifier of the seed being executed.
970
+ current_ac: The acceptance criterion being worked on.
971
+ iteration: Current iteration number.
972
+ depth: Current depth in AC tree (0 = root).
973
+ max_depth: Maximum decomposition depth (default: 5).
974
+ parent_ac: Parent AC content if this is a child AC.
975
+
976
+ Returns:
977
+ Result containing CycleResult on success or ExecutionError on failure.
978
+ """
979
+ from ouroboros.events.decomposition import (
980
+ create_ac_atomicity_checked_event,
981
+ create_ac_marked_atomic_event,
982
+ )
983
+ from ouroboros.execution.atomicity import AtomicityCriteria, check_atomicity
984
+ from ouroboros.execution.decomposition import decompose_ac
985
+
986
+ log.info(
987
+ "execution.cycle_with_decomposition.started",
988
+ execution_id=execution_id,
989
+ seed_id=seed_id,
990
+ depth=depth,
991
+ max_depth=max_depth,
992
+ iteration=iteration,
993
+ )
994
+
995
+ # Check max depth - if reached, force execution without further decomposition
996
+ if depth >= max_depth:
997
+ log.warning(
998
+ "execution.max_depth_reached",
999
+ execution_id=execution_id,
1000
+ depth=depth,
1001
+ max_depth=max_depth,
1002
+ )
1003
+ # Execute normally without decomposition
1004
+ return await self.run_cycle(execution_id, seed_id, current_ac, iteration)
1005
+
1006
+ # Emit cycle started event
1007
+ cycle_started_event = self._emit_event(
1008
+ "execution.cycle.started",
1009
+ execution_id,
1010
+ seed_id,
1011
+ {"iteration": iteration, "current_ac": current_ac, "depth": depth},
1012
+ )
1013
+
1014
+ phase_results: dict[Phase, PhaseResult] = {}
1015
+ all_events: list[BaseEvent] = [cycle_started_event]
1016
+
1017
+ # Phase 1: DISCOVER
1018
+ discover_ctx = PhaseContext(
1019
+ execution_id=execution_id,
1020
+ seed_id=seed_id,
1021
+ current_ac=current_ac,
1022
+ phase=Phase.DISCOVER,
1023
+ iteration=iteration,
1024
+ previous_results={},
1025
+ depth=depth,
1026
+ parent_ac=parent_ac,
1027
+ )
1028
+
1029
+ discover_result = await self.discover(discover_ctx)
1030
+ if discover_result.is_err:
1031
+ cycle_failed_event = self._emit_event(
1032
+ "execution.cycle.failed",
1033
+ execution_id,
1034
+ seed_id,
1035
+ {"iteration": iteration, "failed_phase": "discover", "error": str(discover_result.error)},
1036
+ )
1037
+ all_events.append(cycle_failed_event)
1038
+ return Result.err(discover_result.error)
1039
+
1040
+ phase_results[Phase.DISCOVER] = discover_result.value
1041
+ all_events.extend(discover_result.value.events)
1042
+
1043
+ # Phase 2: DEFINE
1044
+ define_ctx = PhaseContext(
1045
+ execution_id=execution_id,
1046
+ seed_id=seed_id,
1047
+ current_ac=current_ac,
1048
+ phase=Phase.DEFINE,
1049
+ iteration=iteration,
1050
+ previous_results=dict(phase_results),
1051
+ depth=depth,
1052
+ parent_ac=parent_ac,
1053
+ )
1054
+
1055
+ define_result = await self.define(define_ctx)
1056
+ if define_result.is_err:
1057
+ cycle_failed_event = self._emit_event(
1058
+ "execution.cycle.failed",
1059
+ execution_id,
1060
+ seed_id,
1061
+ {"iteration": iteration, "failed_phase": "define", "error": str(define_result.error)},
1062
+ )
1063
+ all_events.append(cycle_failed_event)
1064
+ return Result.err(define_result.error)
1065
+
1066
+ phase_results[Phase.DEFINE] = define_result.value
1067
+ all_events.extend(define_result.value.events)
1068
+
1069
+ # After Define: Check atomicity
1070
+ atomicity_result = await check_atomicity(
1071
+ ac_content=current_ac,
1072
+ llm_adapter=self._llm_adapter,
1073
+ criteria=AtomicityCriteria(),
1074
+ )
1075
+
1076
+ is_atomic = True # Default to atomic if check fails
1077
+ if atomicity_result.is_ok:
1078
+ atomicity = atomicity_result.value
1079
+ is_atomic = atomicity.is_atomic
1080
+
1081
+ # Emit atomicity checked event
1082
+ atomicity_event = create_ac_atomicity_checked_event(
1083
+ ac_id=execution_id,
1084
+ execution_id=execution_id,
1085
+ is_atomic=atomicity.is_atomic,
1086
+ complexity_score=atomicity.complexity_score,
1087
+ tool_count=atomicity.tool_count,
1088
+ estimated_duration=atomicity.estimated_duration,
1089
+ reasoning=atomicity.reasoning,
1090
+ )
1091
+ all_events.append(atomicity_event)
1092
+
1093
+ log.info(
1094
+ "execution.atomicity.checked",
1095
+ execution_id=execution_id,
1096
+ is_atomic=is_atomic,
1097
+ complexity=atomicity.complexity_score,
1098
+ )
1099
+ else:
1100
+ log.warning(
1101
+ "execution.atomicity.check_failed",
1102
+ execution_id=execution_id,
1103
+ error=str(atomicity_result.error),
1104
+ defaulting_to_atomic=True,
1105
+ )
1106
+
1107
+ # If non-atomic and not at max depth, decompose
1108
+ if not is_atomic and depth < max_depth - 1:
1109
+ # Get discover insights for decomposition
1110
+ discover_insights = str(phase_results[Phase.DISCOVER].output.get("insights", ""))
1111
+
1112
+ decompose_result = await decompose_ac(
1113
+ ac_content=current_ac,
1114
+ ac_id=execution_id,
1115
+ execution_id=execution_id,
1116
+ depth=depth,
1117
+ llm_adapter=self._llm_adapter,
1118
+ discover_insights=discover_insights,
1119
+ )
1120
+
1121
+ if decompose_result.is_ok:
1122
+ decomposition = decompose_result.value
1123
+ all_events.extend(decomposition.events)
1124
+
1125
+ log.info(
1126
+ "execution.decomposition.completed",
1127
+ execution_id=execution_id,
1128
+ child_count=len(decomposition.child_acs),
1129
+ )
1130
+
1131
+ # Execute children recursively with SubAgent isolation
1132
+ # Import SubAgent utilities for isolation (AC 4, 5)
1133
+ from ouroboros.execution.subagent import (
1134
+ create_subagent_completed_event,
1135
+ create_subagent_failed_event,
1136
+ create_subagent_started_event,
1137
+ create_subagent_validated_event,
1138
+ validate_child_result,
1139
+ )
1140
+
1141
+ child_results: list[CycleResult] = []
1142
+ for idx, (child_ac, child_id) in enumerate(
1143
+ zip(decomposition.child_acs, decomposition.child_ac_ids, strict=True)
1144
+ ):
1145
+ child_exec_id = f"{execution_id}_child_{idx}"
1146
+
1147
+ # Emit SubAgent started event
1148
+ subagent_started_event = create_subagent_started_event(
1149
+ subagent_id=child_exec_id,
1150
+ parent_execution_id=execution_id,
1151
+ child_ac=child_ac,
1152
+ depth=depth + 1,
1153
+ )
1154
+ all_events.append(subagent_started_event)
1155
+
1156
+ log.info(
1157
+ "execution.subagent.started",
1158
+ subagent_id=child_exec_id,
1159
+ parent_execution_id=execution_id,
1160
+ depth=depth + 1,
1161
+ )
1162
+
1163
+ # Execute child in isolated context
1164
+ # Note: FilteredContext is available via create_filtered_context()
1165
+ # but DoubleDiamond doesn't maintain WorkflowContext state.
1166
+ # The isolation is achieved by:
1167
+ # 1. Passing only child_ac (not parent context)
1168
+ # 2. Using immutable CycleResult
1169
+ # 3. Validating results before integration
1170
+ child_result = await self.run_cycle_with_decomposition(
1171
+ execution_id=child_exec_id,
1172
+ seed_id=seed_id,
1173
+ current_ac=child_ac,
1174
+ iteration=1,
1175
+ depth=depth + 1,
1176
+ max_depth=max_depth,
1177
+ parent_ac=current_ac,
1178
+ )
1179
+
1180
+ if child_result.is_ok:
1181
+ # AC 4: Validate child result before integration
1182
+ validation_result = validate_child_result(
1183
+ child_result.value, child_ac
1184
+ )
1185
+
1186
+ if validation_result.is_ok:
1187
+ validated_child = validation_result.value
1188
+ child_results.append(validated_child)
1189
+ all_events.extend(validated_child.events)
1190
+
1191
+ # Emit validation success event
1192
+ validated_event = create_subagent_validated_event(
1193
+ subagent_id=child_exec_id,
1194
+ parent_execution_id=execution_id,
1195
+ validation_passed=True,
1196
+ )
1197
+ all_events.append(validated_event)
1198
+
1199
+ # Emit SubAgent completed event
1200
+ completed_event = create_subagent_completed_event(
1201
+ subagent_id=child_exec_id,
1202
+ parent_execution_id=execution_id,
1203
+ success=True,
1204
+ child_count=len(validated_child.child_results),
1205
+ )
1206
+ all_events.append(completed_event)
1207
+
1208
+ log.info(
1209
+ "execution.subagent.completed",
1210
+ subagent_id=child_exec_id,
1211
+ success=True,
1212
+ )
1213
+ else:
1214
+ # Validation failed - log but don't crash (AC 5)
1215
+ validation_error = validation_result.error
1216
+ log.warning(
1217
+ "execution.subagent.validation_failed",
1218
+ subagent_id=child_exec_id,
1219
+ error=str(validation_error),
1220
+ )
1221
+
1222
+ # Emit validation failure event
1223
+ validated_event = create_subagent_validated_event(
1224
+ subagent_id=child_exec_id,
1225
+ parent_execution_id=execution_id,
1226
+ validation_passed=False,
1227
+ validation_message=str(validation_error),
1228
+ )
1229
+ all_events.append(validated_event)
1230
+
1231
+ # Emit failed event
1232
+ failed_event = create_subagent_failed_event(
1233
+ subagent_id=child_exec_id,
1234
+ parent_execution_id=execution_id,
1235
+ error_message=f"Validation failed: {validation_error}",
1236
+ is_retriable=False,
1237
+ )
1238
+ all_events.append(failed_event)
1239
+ # Continue with other children (resilience - AC 5)
1240
+ else:
1241
+ # AC 5: Failed SubAgent doesn't crash parent
1242
+ log.error(
1243
+ "execution.subagent.failed",
1244
+ parent_execution_id=execution_id,
1245
+ child_execution_id=child_exec_id,
1246
+ error=str(child_result.error),
1247
+ )
1248
+
1249
+ # Emit SubAgent failed event
1250
+ failed_event = create_subagent_failed_event(
1251
+ subagent_id=child_exec_id,
1252
+ parent_execution_id=execution_id,
1253
+ error_message=str(child_result.error),
1254
+ is_retriable=getattr(child_result.error, "is_retriable", False),
1255
+ )
1256
+ all_events.append(failed_event)
1257
+ # Continue with other children (resilience - AC 5)
1258
+
1259
+ # Emit cycle completed event (decomposed)
1260
+ cycle_completed_event = self._emit_event(
1261
+ "execution.cycle.completed",
1262
+ execution_id,
1263
+ seed_id,
1264
+ {
1265
+ "iteration": iteration,
1266
+ "phases_completed": 2, # Discover + Define
1267
+ "decomposed": True,
1268
+ "child_count": len(child_results),
1269
+ },
1270
+ )
1271
+ all_events.append(cycle_completed_event)
1272
+
1273
+ return Result.ok(
1274
+ CycleResult(
1275
+ execution_id=execution_id,
1276
+ seed_id=seed_id,
1277
+ current_ac=current_ac,
1278
+ success=True,
1279
+ phase_results=phase_results,
1280
+ events=all_events,
1281
+ is_decomposed=True,
1282
+ child_results=tuple(child_results),
1283
+ depth=depth,
1284
+ )
1285
+ )
1286
+ else:
1287
+ log.warning(
1288
+ "execution.decomposition.failed",
1289
+ execution_id=execution_id,
1290
+ error=str(decompose_result.error),
1291
+ continuing_as_atomic=True,
1292
+ )
1293
+ # Fall through to execute as atomic
1294
+
1295
+ # Atomic path: emit marked atomic event
1296
+ atomic_event = create_ac_marked_atomic_event(
1297
+ ac_id=execution_id,
1298
+ execution_id=execution_id,
1299
+ depth=depth,
1300
+ )
1301
+ all_events.append(atomic_event)
1302
+
1303
+ # Continue with Design and Deliver phases
1304
+ # Phase 3: DESIGN
1305
+ design_ctx = PhaseContext(
1306
+ execution_id=execution_id,
1307
+ seed_id=seed_id,
1308
+ current_ac=current_ac,
1309
+ phase=Phase.DESIGN,
1310
+ iteration=iteration,
1311
+ previous_results=dict(phase_results),
1312
+ depth=depth,
1313
+ parent_ac=parent_ac,
1314
+ )
1315
+
1316
+ design_result = await self.design(design_ctx)
1317
+ if design_result.is_err:
1318
+ cycle_failed_event = self._emit_event(
1319
+ "execution.cycle.failed",
1320
+ execution_id,
1321
+ seed_id,
1322
+ {"iteration": iteration, "failed_phase": "design", "error": str(design_result.error)},
1323
+ )
1324
+ all_events.append(cycle_failed_event)
1325
+ return Result.err(design_result.error)
1326
+
1327
+ phase_results[Phase.DESIGN] = design_result.value
1328
+ all_events.extend(design_result.value.events)
1329
+
1330
+ # Phase 4: DELIVER
1331
+ deliver_ctx = PhaseContext(
1332
+ execution_id=execution_id,
1333
+ seed_id=seed_id,
1334
+ current_ac=current_ac,
1335
+ phase=Phase.DELIVER,
1336
+ iteration=iteration,
1337
+ previous_results=dict(phase_results),
1338
+ depth=depth,
1339
+ parent_ac=parent_ac,
1340
+ )
1341
+
1342
+ deliver_result = await self.deliver(deliver_ctx)
1343
+ if deliver_result.is_err:
1344
+ cycle_failed_event = self._emit_event(
1345
+ "execution.cycle.failed",
1346
+ execution_id,
1347
+ seed_id,
1348
+ {"iteration": iteration, "failed_phase": "deliver", "error": str(deliver_result.error)},
1349
+ )
1350
+ all_events.append(cycle_failed_event)
1351
+ return Result.err(deliver_result.error)
1352
+
1353
+ phase_results[Phase.DELIVER] = deliver_result.value
1354
+ all_events.extend(deliver_result.value.events)
1355
+
1356
+ # Emit cycle completed event (atomic)
1357
+ cycle_completed_event = self._emit_event(
1358
+ "execution.cycle.completed",
1359
+ execution_id,
1360
+ seed_id,
1361
+ {"iteration": iteration, "phases_completed": 4, "decomposed": False},
1362
+ )
1363
+ all_events.append(cycle_completed_event)
1364
+
1365
+ log.info(
1366
+ "execution.cycle_with_decomposition.completed",
1367
+ execution_id=execution_id,
1368
+ seed_id=seed_id,
1369
+ depth=depth,
1370
+ is_atomic=True,
1371
+ phases_completed=4,
1372
+ )
1373
+
1374
+ return Result.ok(
1375
+ CycleResult(
1376
+ execution_id=execution_id,
1377
+ seed_id=seed_id,
1378
+ current_ac=current_ac,
1379
+ success=True,
1380
+ phase_results=phase_results,
1381
+ events=all_events,
1382
+ is_decomposed=False,
1383
+ child_results=(),
1384
+ depth=depth,
1385
+ )
1386
+ )