ralphx 0.3.4__py3-none-any.whl → 0.4.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.
Files changed (48) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/adapters/base.py +10 -2
  3. ralphx/adapters/claude_cli.py +222 -82
  4. ralphx/api/routes/auth.py +780 -98
  5. ralphx/api/routes/config.py +3 -56
  6. ralphx/api/routes/export_import.py +6 -9
  7. ralphx/api/routes/loops.py +4 -4
  8. ralphx/api/routes/planning.py +882 -19
  9. ralphx/api/routes/resources.py +528 -6
  10. ralphx/api/routes/stream.py +58 -56
  11. ralphx/api/routes/templates.py +2 -2
  12. ralphx/api/routes/workflows.py +258 -47
  13. ralphx/cli.py +4 -1
  14. ralphx/core/auth.py +372 -172
  15. ralphx/core/database.py +588 -164
  16. ralphx/core/executor.py +170 -19
  17. ralphx/core/loop.py +15 -2
  18. ralphx/core/loop_templates.py +29 -3
  19. ralphx/core/planning_iteration_executor.py +633 -0
  20. ralphx/core/planning_service.py +119 -24
  21. ralphx/core/preview.py +9 -25
  22. ralphx/core/project_db.py +864 -121
  23. ralphx/core/project_export.py +1 -5
  24. ralphx/core/project_import.py +14 -29
  25. ralphx/core/resources.py +28 -2
  26. ralphx/core/sample_project.py +1 -5
  27. ralphx/core/templates.py +9 -9
  28. ralphx/core/workflow_executor.py +32 -3
  29. ralphx/core/workflow_export.py +4 -7
  30. ralphx/core/workflow_import.py +3 -27
  31. ralphx/mcp/__init__.py +6 -2
  32. ralphx/mcp/registry.py +3 -3
  33. ralphx/mcp/tools/diagnostics.py +1 -1
  34. ralphx/mcp/tools/monitoring.py +10 -16
  35. ralphx/mcp/tools/workflows.py +115 -33
  36. ralphx/mcp_server.py +6 -2
  37. ralphx/static/assets/index-BuLI7ffn.css +1 -0
  38. ralphx/static/assets/index-DWvlqOTb.js +264 -0
  39. ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
  40. ralphx/static/index.html +2 -2
  41. ralphx/templates/loop_templates/consumer.md +2 -2
  42. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
  43. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
  44. ralphx/static/assets/index-CcRDyY3b.css +0 -1
  45. ralphx/static/assets/index-CcxfTosc.js +0 -251
  46. ralphx/static/assets/index-CcxfTosc.js.map +0 -1
  47. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
  48. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,633 @@
1
+ """Planning Iteration Executor for RalphX.
2
+
3
+ Replaces the chat-based planning paradigm with prompt-driven iteration loops.
4
+ Users provide a single prompt + iteration count, system runs N iterations
5
+ automatically, each refining the design document.
6
+ """
7
+
8
+ import asyncio
9
+ import difflib
10
+ import json
11
+ import logging
12
+ import re
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import AsyncIterator, Optional
16
+
17
+ from ralphx.adapters.base import AdapterEvent, StreamEvent
18
+ from ralphx.adapters.claude_cli import ClaudeCLIAdapter
19
+ from ralphx.core.project import Project
20
+ from ralphx.core.project_db import ProjectDatabase
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # ============================================================================
25
+ # Configuration
26
+ # ============================================================================
27
+
28
+ # Allowed tools for iteration mode (research + file editing)
29
+ DEFAULT_ITERATION_TOOLS = [
30
+ "WebSearch", # Research best practices, technologies
31
+ "WebFetch", # Deep dive into specific URLs
32
+ "Read", # Read project files for context
33
+ "Glob", # Find relevant files
34
+ "Grep", # Search file contents
35
+ "Edit", # Edit the design document in place
36
+ "Write", # Write/rewrite the design document
37
+ ]
38
+
39
+ # Timeouts and limits
40
+ TIMEOUT_PER_ITERATION = 300 # 5 minutes per iteration
41
+ COOLDOWN_BETWEEN_ITERATIONS = 5 # 5 seconds between iterations
42
+ MAX_ITERATIONS = 10 # Maximum iterations allowed
43
+ HEARTBEAT_INTERVAL = 15 # Heartbeat every 15 seconds
44
+
45
+ # ============================================================================
46
+ # SSE Event Types
47
+ # ============================================================================
48
+
49
+
50
+ class SSEEventType:
51
+ """Server-Sent Event types for iteration streaming."""
52
+
53
+ ITERATION_START = "iteration_start"
54
+ TOOL_USE = "tool_use"
55
+ TOOL_RESULT = "tool_result"
56
+ CONTENT = "content"
57
+ DESIGN_DOC_UPDATED = "design_doc_updated"
58
+ HEARTBEAT = "heartbeat"
59
+ ITERATION_COMPLETE = "iteration_complete"
60
+ ERROR = "error"
61
+ CANCELLED = "cancelled"
62
+ DONE = "done"
63
+
64
+
65
+ # ============================================================================
66
+ # Iteration Prompt Template
67
+ # ============================================================================
68
+
69
+ ITERATION_PROMPT_TEMPLATE = """You are refining a design document. This is iteration {current} of {total}.
70
+
71
+ ## User's Guidance
72
+ {user_prompt}
73
+
74
+ ## Design Document
75
+ The design document is at: {design_doc_file}
76
+ Read it, then use Edit to make targeted changes based on your research.
77
+
78
+ ## Instructions
79
+ 1. Read the design document file at the path above
80
+ 2. Use tools (WebSearch, Read, etc.) to research as needed
81
+ 3. Use Edit to make targeted changes to the design document file
82
+ 4. If the document doesn't exist yet, use Write to create it
83
+ 5. Provide a brief summary of changes in <changes_summary>...</changes_summary> tags
84
+
85
+ Do NOT output the entire document in your response. Edit the file directly using the Edit tool.
86
+ """
87
+
88
+ NO_DOC_PLACEHOLDER = """(No design document exists yet. Create a comprehensive design document based on the user's guidance above.
89
+
90
+ Structure your document with clear sections:
91
+ - Overview / Problem Statement
92
+ - Goals and Requirements
93
+ - Technical Approach
94
+ - Key Components
95
+ - Implementation Plan
96
+ - Open Questions / Considerations)"""
97
+
98
+
99
+ # ============================================================================
100
+ # Executor Class
101
+ # ============================================================================
102
+
103
+
104
+ class PlanningIterationExecutor:
105
+ """Executes N iterations of design doc refinement.
106
+
107
+ Each iteration:
108
+ 1. Writes the current design doc to a file for Claude to edit in place
109
+ 2. Builds a prompt referencing the file path (not the doc content)
110
+ 3. Streams Claude response (with Edit/Write tools)
111
+ 4. Re-reads the file to capture Claude's edits, saves to DB
112
+ 5. Records iteration metrics
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ project: Project,
118
+ pdb: ProjectDatabase,
119
+ session_id: str,
120
+ project_id: Optional[str] = None,
121
+ design_doc_path: Optional[str] = None,
122
+ ):
123
+ """Initialize the executor.
124
+
125
+ Args:
126
+ project: Project object with path.
127
+ pdb: Project database for persistence.
128
+ session_id: Planning session ID.
129
+ project_id: Optional project ID for credentials.
130
+ design_doc_path: Absolute path to design doc file for file-based editing.
131
+ """
132
+ self.project = project
133
+ self.pdb = pdb
134
+ self.session_id = session_id
135
+ self.project_id = project_id
136
+ self.design_doc_path = design_doc_path
137
+ self._adapter: Optional[ClaudeCLIAdapter] = None
138
+ self._cancelled = False
139
+
140
+ def cancel(self) -> None:
141
+ """Request cancellation of the execution loop."""
142
+ self._cancelled = True
143
+
144
+ async def _check_cancelled(self) -> bool:
145
+ """Check if cancellation has been requested.
146
+
147
+ Also checks the database for external cancellation.
148
+ """
149
+ if self._cancelled:
150
+ return True
151
+
152
+ # Check database for cancelled status
153
+ session = self.pdb.get_planning_session(self.session_id)
154
+ if session and session.get("run_status") == "cancelled":
155
+ self._cancelled = True
156
+ return True
157
+
158
+ return False
159
+
160
+ def _load_design_doc(self) -> str:
161
+ """Load the current design document from session artifacts.
162
+
163
+ Returns:
164
+ The design doc content, or empty string if none exists.
165
+ """
166
+ session = self.pdb.get_planning_session(self.session_id)
167
+ if not session:
168
+ return ""
169
+
170
+ artifacts = session.get("artifacts") or {}
171
+ return artifacts.get("design_doc", "")
172
+
173
+ def _save_design_doc(self, content: str) -> None:
174
+ """Save the design document to session artifacts.
175
+
176
+ Args:
177
+ content: The updated design doc content.
178
+ """
179
+ session = self.pdb.get_planning_session(self.session_id)
180
+ if not session:
181
+ return
182
+
183
+ artifacts = session.get("artifacts") or {}
184
+ artifacts["design_doc"] = content
185
+ self.pdb.update_planning_session(self.session_id, artifacts=artifacts)
186
+
187
+ def _build_iteration_prompt(
188
+ self,
189
+ user_prompt: str,
190
+ design_doc_file: str,
191
+ current: int,
192
+ total: int,
193
+ ) -> str:
194
+ """Build the prompt for an iteration.
195
+
196
+ Args:
197
+ user_prompt: User's guidance for this session.
198
+ design_doc_file: Path to the design doc file on disk.
199
+ current: Current iteration number (1-indexed).
200
+ total: Total iterations requested.
201
+
202
+ Returns:
203
+ Full prompt string.
204
+ """
205
+ return ITERATION_PROMPT_TEMPLATE.format(
206
+ current=current,
207
+ total=total,
208
+ user_prompt=user_prompt,
209
+ design_doc_file=design_doc_file,
210
+ )
211
+
212
+ def _extract_design_doc(self, response: str) -> Optional[str]:
213
+ """Extract the design document from Claude's response.
214
+
215
+ Args:
216
+ response: Full response text from Claude.
217
+
218
+ Returns:
219
+ Extracted design doc content, or None if not found.
220
+ """
221
+ # Look for <design_doc>...</design_doc> tags
222
+ match = re.search(
223
+ r"<design_doc>(.*?)</design_doc>",
224
+ response,
225
+ re.DOTALL | re.IGNORECASE,
226
+ )
227
+ if match:
228
+ return match.group(1).strip()
229
+
230
+ # Fallback: if response looks like a design doc (has markdown headers),
231
+ # use the whole response
232
+ if len(response) > 200 and any(h in response for h in ["# ", "## "]):
233
+ logger.warning(
234
+ "No <design_doc> tags found, using response as design doc (has markdown)"
235
+ )
236
+ return response.strip()
237
+
238
+ return None
239
+
240
+ def _extract_summary(self, response: str) -> Optional[str]:
241
+ """Extract the changes summary from Claude's response.
242
+
243
+ Args:
244
+ response: Full response text from Claude.
245
+
246
+ Returns:
247
+ Extracted summary, or None if not found.
248
+ """
249
+ match = re.search(
250
+ r"<changes_summary>(.*?)</changes_summary>",
251
+ response,
252
+ re.DOTALL | re.IGNORECASE,
253
+ )
254
+ if match:
255
+ return match.group(1).strip()
256
+ return None
257
+
258
+ def _calculate_diff(
259
+ self, old_doc: str, new_doc: str
260
+ ) -> tuple[int, int]:
261
+ """Calculate characters added and removed using sequence matching.
262
+
263
+ Uses difflib to compute actual additions and removals, not just
264
+ length difference. This correctly handles replacements (e.g., 100
265
+ chars replaced with 100 different chars shows both adds and removes).
266
+
267
+ Args:
268
+ old_doc: Previous document content.
269
+ new_doc: New document content.
270
+
271
+ Returns:
272
+ Tuple of (chars_added, chars_removed).
273
+ """
274
+ if not old_doc and not new_doc:
275
+ return 0, 0
276
+ if not old_doc:
277
+ return len(new_doc), 0
278
+ if not new_doc:
279
+ return 0, len(old_doc)
280
+
281
+ old_lines = old_doc.splitlines(keepends=True)
282
+ new_lines = new_doc.splitlines(keepends=True)
283
+
284
+ chars_added = 0
285
+ chars_removed = 0
286
+
287
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(
288
+ None, old_lines, new_lines
289
+ ).get_opcodes():
290
+ if tag == "replace":
291
+ chars_removed += sum(len(line) for line in old_lines[i1:i2])
292
+ chars_added += sum(len(line) for line in new_lines[j1:j2])
293
+ elif tag == "delete":
294
+ chars_removed += sum(len(line) for line in old_lines[i1:i2])
295
+ elif tag == "insert":
296
+ chars_added += sum(len(line) for line in new_lines[j1:j2])
297
+
298
+ return chars_added, chars_removed
299
+
300
+ def _compute_unified_diff(self, old_doc: str, new_doc: str) -> str:
301
+ """Compute a unified diff between old and new document content.
302
+
303
+ Args:
304
+ old_doc: Previous document content.
305
+ new_doc: New document content.
306
+
307
+ Returns:
308
+ Unified diff string.
309
+ """
310
+ return "".join(
311
+ difflib.unified_diff(
312
+ old_doc.splitlines(keepends=True),
313
+ new_doc.splitlines(keepends=True),
314
+ fromfile="before",
315
+ tofile="after",
316
+ )
317
+ )
318
+
319
+ def _resolve_doc_path(self) -> Path:
320
+ """Resolve the design doc file path.
321
+
322
+ Uses the configured design_doc_path if set, otherwise defaults to
323
+ a session-specific file under .ralphx/resources/.
324
+ """
325
+ if self.design_doc_path:
326
+ return Path(self.design_doc_path)
327
+ # Default: project/.ralphx/resources/design-doc-<session>.md
328
+ return Path(self.project.path) / ".ralphx" / "resources" / f"design-doc-iteration-{self.session_id}.md"
329
+
330
+ async def run(
331
+ self,
332
+ prompt: str,
333
+ iterations: int,
334
+ model: str = "opus",
335
+ tools: Optional[list[str]] = None,
336
+ ) -> AsyncIterator[dict]:
337
+ """Run the iteration loop.
338
+
339
+ Args:
340
+ prompt: User's guidance prompt.
341
+ iterations: Number of iterations to run.
342
+ model: Model to use (default: opus for design docs).
343
+ tools: Tools to enable (default: DEFAULT_ITERATION_TOOLS).
344
+
345
+ Yields:
346
+ SSE event dicts as execution progresses.
347
+ """
348
+ if tools is None:
349
+ tools = DEFAULT_ITERATION_TOOLS
350
+
351
+ iterations = min(iterations, MAX_ITERATIONS)
352
+
353
+ # Resolve the file path Claude will edit directly
354
+ doc_file_path = self._resolve_doc_path()
355
+
356
+ # Update session status to running
357
+ self.pdb.update_planning_session(
358
+ self.session_id,
359
+ run_status="running",
360
+ current_iteration=0,
361
+ iterations_completed=0,
362
+ )
363
+
364
+ completed_iterations = 0
365
+ last_event_time = asyncio.get_event_loop().time()
366
+
367
+ try:
368
+ for i in range(1, iterations + 1):
369
+ # Check for cancellation before each iteration
370
+ if await self._check_cancelled():
371
+ yield {
372
+ "type": SSEEventType.CANCELLED,
373
+ "iterations_completed": completed_iterations,
374
+ }
375
+ self.pdb.update_planning_session(
376
+ self.session_id,
377
+ run_status="cancelled",
378
+ iterations_completed=completed_iterations,
379
+ )
380
+ return
381
+
382
+ # Update current iteration
383
+ self.pdb.update_planning_session(
384
+ self.session_id,
385
+ current_iteration=i,
386
+ )
387
+
388
+ # Create iteration record
389
+ iteration_record = self.pdb.create_planning_iteration(
390
+ session_id=self.session_id,
391
+ iteration_number=i,
392
+ status="running",
393
+ )
394
+ iteration_id = iteration_record["id"] if iteration_record else None
395
+ if iteration_id:
396
+ self.pdb.start_planning_iteration(iteration_id)
397
+
398
+ yield {
399
+ "type": SSEEventType.ITERATION_START,
400
+ "iteration": i,
401
+ "total": iterations,
402
+ }
403
+
404
+ # Load current design doc and write to file for Claude to edit
405
+ old_doc = self._load_design_doc()
406
+ doc_file_path.parent.mkdir(parents=True, exist_ok=True)
407
+ if old_doc:
408
+ doc_file_path.write_text(old_doc)
409
+ elif doc_file_path.exists():
410
+ old_doc = doc_file_path.read_text()
411
+
412
+ # Build iteration prompt (references file path, not doc content)
413
+ full_prompt = self._build_iteration_prompt(
414
+ prompt, str(doc_file_path), i, iterations
415
+ )
416
+
417
+ # Create adapter for this iteration
418
+ self._adapter = ClaudeCLIAdapter(
419
+ project_path=Path(self.project.path),
420
+ project_id=self.project_id,
421
+ )
422
+
423
+ # Stream Claude response
424
+ response_text = ""
425
+ tool_calls: list[dict] = []
426
+ error_message: Optional[str] = None
427
+
428
+ try:
429
+ async for event in self._adapter.stream(
430
+ prompt=full_prompt,
431
+ model=model,
432
+ tools=tools,
433
+ timeout=TIMEOUT_PER_ITERATION,
434
+ ):
435
+ last_event_time = asyncio.get_event_loop().time()
436
+
437
+ if event.type == AdapterEvent.TEXT:
438
+ text = event.text or ""
439
+ response_text += text
440
+ yield {
441
+ "type": SSEEventType.CONTENT,
442
+ "text": text,
443
+ }
444
+
445
+ elif event.type == AdapterEvent.TOOL_USE:
446
+ tool_call = {
447
+ "tool": event.tool_name,
448
+ "input_preview": str(event.tool_input)[:100],
449
+ "start_time": datetime.utcnow().isoformat(),
450
+ }
451
+ tool_calls.append(tool_call)
452
+ yield {
453
+ "type": SSEEventType.TOOL_USE,
454
+ "tool": event.tool_name,
455
+ "input": event.tool_input,
456
+ }
457
+
458
+ elif event.type == AdapterEvent.TOOL_RESULT:
459
+ # Update the last tool call with result
460
+ if tool_calls:
461
+ tool_calls[-1]["duration_ms"] = 0 # Could calculate
462
+ result_preview = str(event.tool_result or "")[:200]
463
+ if len(str(event.tool_result or "")) > 200:
464
+ result_preview += "..."
465
+ yield {
466
+ "type": SSEEventType.TOOL_RESULT,
467
+ "tool": event.tool_name,
468
+ "result": result_preview,
469
+ }
470
+
471
+ elif event.type == AdapterEvent.ERROR:
472
+ error_message = event.error_message
473
+ break
474
+
475
+ elif event.type == AdapterEvent.COMPLETE:
476
+ break
477
+
478
+ except asyncio.TimeoutError:
479
+ error_message = f"Iteration {i} timed out"
480
+ logger.warning(f"Iteration {i} timed out after {TIMEOUT_PER_ITERATION}s")
481
+
482
+ except Exception as e:
483
+ error_message = f"Error in iteration {i}: {str(e)}"
484
+ logger.warning(f"Error in iteration {i}: {str(e)}", exc_info=True)
485
+
486
+ # Process iteration result
487
+ if error_message:
488
+ if iteration_id:
489
+ self.pdb.fail_planning_iteration(iteration_id, error_message)
490
+ yield {
491
+ "type": SSEEventType.ERROR,
492
+ "message": error_message,
493
+ "iteration": i,
494
+ }
495
+ # Continue to next iteration on non-fatal errors
496
+ continue
497
+
498
+ # Re-read the file to get Claude's edits
499
+ summary = self._extract_summary(response_text)
500
+ new_doc = ""
501
+ if doc_file_path.exists():
502
+ try:
503
+ new_doc = doc_file_path.read_text()
504
+ except Exception as e:
505
+ logger.warning(f"Failed to read design doc file: {e}")
506
+
507
+ if new_doc and new_doc != old_doc:
508
+ # Calculate diff
509
+ chars_added, chars_removed = self._calculate_diff(old_doc, new_doc)
510
+ diff_text = self._compute_unified_diff(old_doc or "", new_doc)
511
+
512
+ # Save the updated design doc to DB artifacts
513
+ self._save_design_doc(new_doc)
514
+
515
+ yield {
516
+ "type": SSEEventType.DESIGN_DOC_UPDATED,
517
+ "chars_added": chars_added,
518
+ "chars_removed": chars_removed,
519
+ }
520
+
521
+ # Complete iteration record
522
+ if iteration_id:
523
+ self.pdb.complete_planning_iteration(
524
+ iteration_id,
525
+ chars_added=chars_added,
526
+ chars_removed=chars_removed,
527
+ tool_calls=tool_calls[:10], # Limit stored tool calls
528
+ summary=summary,
529
+ diff_text=diff_text,
530
+ doc_before=old_doc or "",
531
+ doc_after=new_doc,
532
+ )
533
+ elif new_doc == old_doc and old_doc:
534
+ # File unchanged — Claude may not have edited it
535
+ # Still count as completed if no error occurred
536
+ if iteration_id:
537
+ self.pdb.complete_planning_iteration(
538
+ iteration_id,
539
+ chars_added=0,
540
+ chars_removed=0,
541
+ tool_calls=tool_calls[:10],
542
+ summary=summary or "No changes made",
543
+ diff_text="",
544
+ doc_before=old_doc or "",
545
+ doc_after=old_doc or "",
546
+ )
547
+ else:
548
+ # No doc file at all — fallback: try extracting from response text
549
+ fallback_doc = self._extract_design_doc(response_text)
550
+ if fallback_doc:
551
+ self._save_design_doc(fallback_doc)
552
+ doc_file_path.write_text(fallback_doc)
553
+ chars_added, chars_removed = self._calculate_diff(old_doc, fallback_doc)
554
+ diff_text = self._compute_unified_diff(old_doc or "", fallback_doc)
555
+ yield {
556
+ "type": SSEEventType.DESIGN_DOC_UPDATED,
557
+ "chars_added": chars_added,
558
+ "chars_removed": chars_removed,
559
+ }
560
+ if iteration_id:
561
+ self.pdb.complete_planning_iteration(
562
+ iteration_id,
563
+ chars_added=chars_added,
564
+ chars_removed=chars_removed,
565
+ tool_calls=tool_calls[:10],
566
+ summary=summary,
567
+ diff_text=diff_text,
568
+ doc_before=old_doc or "",
569
+ doc_after=fallback_doc,
570
+ )
571
+ else:
572
+ if iteration_id:
573
+ self.pdb.fail_planning_iteration(
574
+ iteration_id,
575
+ "No design doc changes detected",
576
+ )
577
+ yield {
578
+ "type": SSEEventType.ERROR,
579
+ "message": "No design doc changes detected",
580
+ "iteration": i,
581
+ }
582
+ continue
583
+
584
+ completed_iterations = i
585
+
586
+ yield {
587
+ "type": SSEEventType.ITERATION_COMPLETE,
588
+ "iteration": i,
589
+ "iteration_id": iteration_id,
590
+ "summary": summary or "Updated design document",
591
+ }
592
+
593
+ # Update session progress
594
+ self.pdb.update_planning_session(
595
+ self.session_id,
596
+ iterations_completed=completed_iterations,
597
+ )
598
+
599
+ # Cooldown between iterations (unless this is the last one)
600
+ if i < iterations:
601
+ await asyncio.sleep(COOLDOWN_BETWEEN_ITERATIONS)
602
+
603
+ # All iterations complete — mark run as completed but keep session active
604
+ # so user can review results and explicitly complete the planning step
605
+ self.pdb.update_planning_session(
606
+ self.session_id,
607
+ run_status="completed",
608
+ iterations_completed=completed_iterations,
609
+ )
610
+
611
+ yield {
612
+ "type": SSEEventType.DONE,
613
+ "iterations_completed": completed_iterations,
614
+ }
615
+
616
+ except Exception as e:
617
+ logger.error(f"Fatal error in iteration executor: {e}", exc_info=True)
618
+ self.pdb.update_planning_session(
619
+ self.session_id,
620
+ run_status="error",
621
+ error_message=str(e), # Internal DB record keeps full error
622
+ iterations_completed=completed_iterations,
623
+ )
624
+ yield {
625
+ "type": SSEEventType.ERROR,
626
+ "message": "Execution failed unexpectedly. Check server logs for details.",
627
+ "fatal": True,
628
+ }
629
+
630
+ async def stop(self) -> None:
631
+ """Stop any running Claude process."""
632
+ if self._adapter:
633
+ await self._adapter.stop()