weco 0.2.28__py3-none-any.whl → 0.3.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.
weco/optimizer.py CHANGED
@@ -11,13 +11,14 @@ from typing import Optional
11
11
  from rich.console import Console
12
12
  from rich.live import Live
13
13
  from rich.panel import Panel
14
-
14
+ from rich.prompt import Confirm
15
15
  from .api import (
16
16
  start_optimization_run,
17
17
  evaluate_feedback_then_suggest_next_solution,
18
18
  get_optimization_run_status,
19
19
  send_heartbeat,
20
20
  report_termination,
21
+ resume_optimization_run,
21
22
  )
22
23
  from .auth import handle_authentication
23
24
  from .panels import (
@@ -29,16 +30,7 @@ from .panels import (
29
30
  create_optimization_layout,
30
31
  create_end_optimization_layout,
31
32
  )
32
- from .utils import (
33
- read_api_keys_from_env,
34
- read_additional_instructions,
35
- read_from_path,
36
- write_to_path,
37
- run_evaluation,
38
- smooth_update,
39
- format_number,
40
- )
41
- from .constants import DEFAULT_API_TIMEOUT
33
+ from .utils import read_additional_instructions, read_from_path, write_to_path, run_evaluation, smooth_update
42
34
 
43
35
 
44
36
  def save_execution_output(runs_dir: pathlib.Path, step: int, output: str) -> None:
@@ -100,6 +92,36 @@ class HeartbeatSender(threading.Thread):
100
92
  # The loop will break due to the exception, and thread will terminate via finally.
101
93
 
102
94
 
95
+ def get_best_node_from_status(status_response: dict) -> Optional[Node]:
96
+ """Extract the best node from a status response as a panels.Node instance."""
97
+ if status_response.get("best_result") is not None:
98
+ return Node(
99
+ id=status_response["best_result"]["solution_id"],
100
+ parent_id=status_response["best_result"]["parent_id"],
101
+ code=status_response["best_result"]["code"],
102
+ metric=status_response["best_result"]["metric_value"],
103
+ is_buggy=status_response["best_result"]["is_buggy"],
104
+ )
105
+ return None
106
+
107
+
108
+ def get_node_from_status(status_response: dict, solution_id: str) -> Node:
109
+ """Find the node with the given solution_id from a status response; raise if not found."""
110
+ nodes = status_response.get("nodes") or []
111
+ for node_data in nodes:
112
+ if node_data.get("solution_id") == solution_id:
113
+ return Node(
114
+ id=node_data["solution_id"],
115
+ parent_id=node_data["parent_id"],
116
+ code=node_data["code"],
117
+ metric=node_data["metric_value"],
118
+ is_buggy=node_data["is_buggy"],
119
+ )
120
+ raise ValueError(
121
+ "Current solution node not found in the optimization status response. This may indicate a synchronization issue with the backend."
122
+ )
123
+
124
+
103
125
  def execute_optimization(
104
126
  source: str,
105
127
  eval_command: str,
@@ -148,6 +170,7 @@ def execute_optimization(
148
170
  auth_headers=current_auth_headers_for_heartbeat,
149
171
  timeout=3,
150
172
  )
173
+ console.print(f"\n[cyan]To resume this run, use:[/] [bold cyan]weco resume {current_run_id_for_heartbeat}[/]")
151
174
 
152
175
  # Exit gracefully
153
176
  sys.exit(0)
@@ -161,12 +184,10 @@ def execute_optimization(
161
184
  user_stop_requested_flag = False
162
185
 
163
186
  try:
164
- llm_api_keys = read_api_keys_from_env()
165
-
166
- # --- Login/Authentication Handling ---
167
- weco_api_key, auth_headers = handle_authentication(console, llm_api_keys)
168
- if weco_api_key is None and not llm_api_keys:
169
- # Authentication failed and no LLM keys available
187
+ # --- Login/Authentication Handling (now mandatory) ---
188
+ weco_api_key, auth_headers = handle_authentication(console)
189
+ if weco_api_key is None:
190
+ # Authentication failed or user declined
170
191
  return False
171
192
 
172
193
  current_auth_headers_for_heartbeat = auth_headers
@@ -176,9 +197,8 @@ def execute_optimization(
176
197
 
177
198
  # Determine the model to use
178
199
  if model is None:
179
- from .utils import determine_default_model
180
-
181
- model = determine_default_model(llm_api_keys)
200
+ # Default to o4-mini with credit-based billing
201
+ model = "o4-mini"
182
202
 
183
203
  code_generator_config = {"model": model}
184
204
  evaluator_config = {"model": model, "include_analysis": True}
@@ -187,7 +207,6 @@ def execute_optimization(
187
207
  "debug_prob": 0.5,
188
208
  "max_debug_depth": max(1, math.ceil(0.1 * steps)),
189
209
  }
190
- api_timeout = DEFAULT_API_TIMEOUT
191
210
  processed_additional_instructions = read_additional_instructions(additional_instructions=additional_instructions)
192
211
  source_fp = pathlib.Path(source)
193
212
  source_code = read_from_path(fp=source_fp, is_json=False)
@@ -204,6 +223,7 @@ def execute_optimization(
204
223
  run_response = start_optimization_run(
205
224
  console=console,
206
225
  source_code=source_code,
226
+ source_path=str(source_fp),
207
227
  evaluation_command=eval_command,
208
228
  metric_name=metric,
209
229
  maximize=maximize,
@@ -212,9 +232,10 @@ def execute_optimization(
212
232
  evaluator_config=evaluator_config,
213
233
  search_policy_config=search_policy_config,
214
234
  additional_instructions=processed_additional_instructions,
215
- api_keys=llm_api_keys,
235
+ eval_timeout=eval_timeout,
236
+ save_logs=save_logs,
237
+ log_dir=log_dir,
216
238
  auth_headers=auth_headers,
217
- timeout=api_timeout,
218
239
  )
219
240
  # Indicate the endpoint failed to return a response and the optimization was unsuccessful
220
241
  if run_response is None:
@@ -263,8 +284,6 @@ def execute_optimization(
263
284
  summary_panel.set_run_name(run_name=run_name)
264
285
  # Set the step of the progress bar
265
286
  summary_panel.set_step(step=0)
266
- # Update the token counts
267
- summary_panel.update_token_counts(usage=run_response["usage"])
268
287
  summary_panel.update_thinking(thinking=run_response["plan"])
269
288
  # Build the metric tree
270
289
  tree_panel.build_metric_tree(
@@ -324,7 +343,7 @@ def execute_optimization(
324
343
  if run_id:
325
344
  try:
326
345
  current_status_response = get_optimization_run_status(
327
- console=console, run_id=run_id, include_history=False, timeout=(10, 30), auth_headers=auth_headers
346
+ console=console, run_id=run_id, include_history=False, auth_headers=auth_headers
328
347
  )
329
348
  current_run_status_val = current_status_response.get("status")
330
349
  if current_run_status_val == "stopping":
@@ -339,23 +358,21 @@ def execute_optimization(
339
358
  # Send feedback and get next suggestion
340
359
  eval_and_next_solution_response = evaluate_feedback_then_suggest_next_solution(
341
360
  console=console,
361
+ step=step,
342
362
  run_id=run_id,
343
363
  execution_output=term_out,
344
364
  additional_instructions=current_additional_instructions,
345
- api_keys=llm_api_keys,
346
365
  auth_headers=auth_headers,
347
- timeout=api_timeout,
348
366
  )
349
367
  # Save next solution (.runs/<run-id>/step_<step>.<extension>)
350
368
  write_to_path(fp=runs_dir / f"step_{step}{source_fp.suffix}", content=eval_and_next_solution_response["code"])
351
369
  # Write the next solution to the source file
352
370
  write_to_path(fp=source_fp, content=eval_and_next_solution_response["code"])
353
371
  status_response = get_optimization_run_status(
354
- console=console, run_id=run_id, include_history=True, timeout=api_timeout, auth_headers=auth_headers
372
+ console=console, run_id=run_id, include_history=True, auth_headers=auth_headers
355
373
  )
356
- # Update the step of the progress bar, token counts, plan and metric tree
374
+ # Update the step of the progress bar, plan and metric tree
357
375
  summary_panel.set_step(step=step)
358
- summary_panel.update_token_counts(usage=eval_and_next_solution_response["usage"])
359
376
  summary_panel.update_thinking(thinking=eval_and_next_solution_response["plan"])
360
377
 
361
378
  nodes_list_from_status = status_response.get("nodes")
@@ -364,32 +381,10 @@ def execute_optimization(
364
381
 
365
382
  # Update the solution panels with the next solution and best solution (and score)
366
383
  # Figure out if we have a best solution so far
367
- if status_response["best_result"] is not None:
368
- best_solution_node = Node(
369
- id=status_response["best_result"]["solution_id"],
370
- parent_id=status_response["best_result"]["parent_id"],
371
- code=status_response["best_result"]["code"],
372
- metric=status_response["best_result"]["metric_value"],
373
- is_buggy=status_response["best_result"]["is_buggy"],
374
- )
375
- else:
376
- best_solution_node = None
377
-
378
- current_solution_node = None
379
- if status_response.get("nodes"):
380
- for node_data in status_response["nodes"]:
381
- if node_data["solution_id"] == eval_and_next_solution_response["solution_id"]:
382
- current_solution_node = Node(
383
- id=node_data["solution_id"],
384
- parent_id=node_data["parent_id"],
385
- code=node_data["code"],
386
- metric=node_data["metric_value"],
387
- is_buggy=node_data["is_buggy"],
388
- )
389
- if current_solution_node is None:
390
- raise ValueError(
391
- "Current solution node not found in the optimization status response. This may indicate a synchronization issue with the backend."
392
- )
384
+ best_solution_node = get_best_node_from_status(status_response=status_response)
385
+ current_solution_node = get_node_from_status(
386
+ status_response=status_response, solution_id=eval_and_next_solution_response["solution_id"]
387
+ )
393
388
 
394
389
  # Update the solution panels with the current and best solution
395
390
  solution_panels.update(current_node=current_solution_node, best_node=best_solution_node)
@@ -426,17 +421,15 @@ def execute_optimization(
426
421
  # Evaluate the final solution thats been generated
427
422
  eval_and_next_solution_response = evaluate_feedback_then_suggest_next_solution(
428
423
  console=console,
424
+ step=steps,
429
425
  run_id=run_id,
430
426
  execution_output=term_out,
431
427
  additional_instructions=current_additional_instructions,
432
- api_keys=llm_api_keys,
433
- timeout=api_timeout,
434
428
  auth_headers=auth_headers,
435
429
  )
436
430
  summary_panel.set_step(step=steps)
437
- summary_panel.update_token_counts(usage=eval_and_next_solution_response["usage"])
438
431
  status_response = get_optimization_run_status(
439
- console=console, run_id=run_id, include_history=True, timeout=api_timeout, auth_headers=auth_headers
432
+ console=console, run_id=run_id, include_history=True, auth_headers=auth_headers
440
433
  )
441
434
  # No need to update the plan panel since we have finished the optimization
442
435
  # Get the optimization run status for
@@ -450,16 +443,7 @@ def execute_optimization(
450
443
  # No neeed to update the current solution panel since we have finished the optimization
451
444
  # We only need to update the best solution panel
452
445
  # Figure out if we have a best solution so far
453
- if status_response["best_result"] is not None:
454
- best_solution_node = Node(
455
- id=status_response["best_result"]["solution_id"],
456
- parent_id=status_response["best_result"]["parent_id"],
457
- code=status_response["best_result"]["code"],
458
- metric=status_response["best_result"]["metric_value"],
459
- is_buggy=status_response["best_result"]["is_buggy"],
460
- )
461
- else:
462
- best_solution_node = None
446
+ best_solution_node = get_best_node_from_status(status_response=status_response)
463
447
  solution_panels.update(current_node=None, best_node=best_solution_node)
464
448
  _, best_solution_panel = solution_panels.get_display(current_step=steps)
465
449
  # Update the end optimization layout
@@ -476,24 +460,10 @@ def execute_optimization(
476
460
  # If the best solution does not exist or is has not been measured at the end of the optimization
477
461
  # save the original solution as the best solution
478
462
  if best_solution_node is not None:
479
- best_solution_code = best_solution_node.code
480
- best_solution_score = best_solution_node.metric
463
+ best_solution_content = best_solution_node.code
481
464
  else:
482
- best_solution_code = None
483
- best_solution_score = None
465
+ best_solution_content = read_from_path(fp=runs_dir / f"step_0{source_fp.suffix}", is_json=False)
484
466
 
485
- if best_solution_code is None or best_solution_score is None:
486
- best_solution_content = f"# Weco could not find a better solution\n\n{read_from_path(fp=runs_dir / f'step_0{source_fp.suffix}', is_json=False)}"
487
- else:
488
- # Format score for the comment
489
- best_score_str = (
490
- format_number(best_solution_score)
491
- if best_solution_score is not None and isinstance(best_solution_score, (int, float))
492
- else "N/A"
493
- )
494
- best_solution_content = (
495
- f"# Best solution from Weco with a score of {best_score_str}\n\n{best_solution_code}"
496
- )
497
467
  # Save best solution to .runs/<run-id>/best.<extension>
498
468
  write_to_path(fp=runs_dir / f"best{source_fp.suffix}", content=best_solution_content)
499
469
  # write the best solution to the source file
@@ -509,6 +479,7 @@ def execute_optimization(
509
479
  except Exception:
510
480
  error_message = str(e)
511
481
  console.print(Panel(f"[bold red]Error: {error_message}", title="[bold red]Optimization Error", border_style="red"))
482
+ console.print(f"\n[cyan]To resume this run, use:[/] [bold cyan]weco resume {run_id}[/]")
512
483
  # Ensure optimization_completed_normally is False
513
484
  optimization_completed_normally = False
514
485
  finally:
@@ -535,10 +506,367 @@ def execute_optimization(
535
506
  else "CLI terminated unexpectedly without a specific exception captured."
536
507
  )
537
508
 
538
- report_termination(run_id, status, reason, details, current_auth_headers_for_heartbeat)
509
+ report_termination(
510
+ run_id=run_id,
511
+ status_update=status,
512
+ reason=reason,
513
+ details=details,
514
+ auth_headers=current_auth_headers_for_heartbeat,
515
+ )
539
516
 
540
517
  # Handle exit
541
518
  if user_stop_requested_flag:
542
519
  console.print("[yellow]Run terminated by user request.[/]")
520
+ console.print(f"\n[cyan]To resume this run, use:[/] [bold cyan]weco resume {run_id}[/]")
521
+
522
+ return optimization_completed_normally or user_stop_requested_flag
523
+
524
+
525
+ def resume_optimization(run_id: str, console: Optional[Console] = None) -> bool:
526
+ """Resume an interrupted run from the most recent node and continue optimization."""
527
+ if console is None:
528
+ console = Console()
529
+
530
+ # Globals for this optimization run
531
+ heartbeat_thread = None
532
+ stop_heartbeat_event = threading.Event()
533
+ current_run_id_for_heartbeat = None
534
+ current_auth_headers_for_heartbeat = {}
535
+
536
+ # Signal handler for this optimization run
537
+ def signal_handler(signum, frame):
538
+ signal_name = signal.Signals(signum).name
539
+ console.print(f"\n[bold yellow]Termination signal ({signal_name}) received. Shutting down...[/]")
540
+ stop_heartbeat_event.set()
541
+ if heartbeat_thread and heartbeat_thread.is_alive():
542
+ heartbeat_thread.join(timeout=2)
543
+ if current_run_id_for_heartbeat:
544
+ report_termination(
545
+ run_id=current_run_id_for_heartbeat,
546
+ status_update="terminated",
547
+ reason=f"user_terminated_{signal_name.lower()}",
548
+ details=f"Process terminated by signal {signal_name} ({signum}).",
549
+ auth_headers=current_auth_headers_for_heartbeat,
550
+ timeout=3,
551
+ )
552
+ console.print(f"\n[cyan]To resume this run, use:[/] [bold cyan]weco resume {current_run_id_for_heartbeat}[/]")
553
+ sys.exit(0)
554
+
555
+ # Set up signal handlers for this run
556
+ original_sigint_handler = signal.signal(signal.SIGINT, signal_handler)
557
+ original_sigterm_handler = signal.signal(signal.SIGTERM, signal_handler)
558
+
559
+ optimization_completed_normally = False
560
+ user_stop_requested_flag = False
561
+
562
+ try:
563
+ # --- Login/Authentication Handling (now mandatory) ---
564
+ weco_api_key, auth_headers = handle_authentication(console)
565
+ if weco_api_key is None:
566
+ # Authentication failed or user declined
567
+ return False
568
+
569
+ current_auth_headers_for_heartbeat = auth_headers
570
+
571
+ # Fetch status first for validation and to display confirmation info
572
+ try:
573
+ status = get_optimization_run_status(
574
+ console=console, run_id=run_id, include_history=True, auth_headers=auth_headers
575
+ )
576
+ except Exception as e:
577
+ console.print(
578
+ Panel(f"[bold red]Error fetching run status: {e}", title="[bold red]Resume Error", border_style="red")
579
+ )
580
+ return False
581
+
582
+ run_status_val = status.get("status")
583
+ if run_status_val not in ("error", "terminated"):
584
+ console.print(
585
+ Panel(
586
+ f"Run {run_id} cannot be resumed (status: {run_status_val}). Only 'error' or 'terminated' runs can be resumed.",
587
+ title="[bold yellow]Resume Not Allowed",
588
+ border_style="yellow",
589
+ )
590
+ )
591
+ return False
592
+
593
+ objective = status.get("objective", {})
594
+ metric_name = objective.get("metric_name", "metric")
595
+ maximize = bool(objective.get("maximize", True))
596
+
597
+ optimizer = status.get("optimizer", {})
598
+
599
+ console.print("[cyan]Resume Run Confirmation[/]")
600
+ console.print(f" Run ID: {run_id}")
601
+ console.print(f" Run Name: {status.get('run_name', 'N/A')}")
602
+ console.print(f" Status: {run_status_val}")
603
+ # Objective and model
604
+ console.print(f" Objective: {metric_name} ({'maximize' if maximize else 'minimize'})")
605
+ model_name = (
606
+ (optimizer.get("code_generator") or {}).get("model")
607
+ or (optimizer.get("evaluator") or {}).get("model")
608
+ or "unknown"
609
+ )
610
+ console.print(f" Model: {model_name}")
611
+ console.print(f" Eval Command: {objective.get('evaluation_command', 'N/A')}")
612
+ # Steps summary
613
+ total_steps = optimizer.get("steps")
614
+ current_step = int(status["current_step"])
615
+ steps_remaining = int(total_steps) - int(current_step)
616
+ console.print(f" Total Steps: {total_steps} | Resume Step: {current_step} | Steps Remaining: {steps_remaining}")
617
+ console.print(f" Last Updated: {status.get('updated_at', 'N/A')}")
618
+ unchanged = Confirm.ask(
619
+ "Have you kept the source file and evaluation command unchanged since the original run?", default=True
620
+ )
621
+ if not unchanged:
622
+ console.print("[yellow]Resume cancelled. Please start a new run if the environment changed.[/]")
623
+ return False
624
+
625
+ # Call backend to prepare resume
626
+ resume_resp = resume_optimization_run(console=console, run_id=run_id, auth_headers=auth_headers)
627
+ if resume_resp is None:
628
+ return False
629
+
630
+ eval_command = resume_resp["evaluation_command"]
631
+ source_path = resume_resp.get("source_path")
632
+
633
+ # Use backend-saved values
634
+ log_dir = resume_resp.get("log_dir", ".runs")
635
+ save_logs = bool(resume_resp.get("save_logs", False))
636
+ eval_timeout = resume_resp.get("eval_timeout")
637
+ additional_instructions = resume_resp.get("additional_instructions")
638
+
639
+ # Write last solution code to source path
640
+ source_fp = pathlib.Path(source_path)
641
+ source_fp.parent.mkdir(parents=True, exist_ok=True)
642
+ code_to_restore = resume_resp.get("code") or resume_resp.get("source_code") or ""
643
+ write_to_path(fp=source_fp, content=code_to_restore)
644
+
645
+ # Prepare UI panels
646
+ summary_panel = SummaryPanel(
647
+ maximize=maximize, metric_name=metric_name, total_steps=total_steps, model=model_name, runs_dir=log_dir
648
+ )
649
+ summary_panel.set_run_id(run_id=resume_resp["run_id"])
650
+ if resume_resp.get("run_name"):
651
+ summary_panel.set_run_name(resume_resp.get("run_name"))
652
+ summary_panel.set_step(step=current_step)
653
+ summary_panel.update_thinking(resume_resp.get("plan"))
654
+
655
+ solution_panels = SolutionPanels(metric_name=metric_name, source_fp=source_fp)
656
+ eval_output_panel = EvaluationOutputPanel()
657
+ tree_panel = MetricTreePanel(maximize=maximize)
658
+ layout = create_optimization_layout()
659
+ end_optimization_layout = create_end_optimization_layout()
660
+
661
+ # Build tree from nodes returned by status (history)
662
+ nodes_list_from_status = status.get("nodes") or []
663
+ tree_panel.build_metric_tree(nodes=nodes_list_from_status)
664
+
665
+ # Compute best and current nodes
666
+ best_solution_node = get_best_node_from_status(status_response=status)
667
+ current_solution_node = get_node_from_status(status_response=status, solution_id=resume_resp.get("solution_id"))
668
+
669
+ # Ensure runs dir exists
670
+ runs_dir = pathlib.Path(log_dir) / resume_resp["run_id"]
671
+ runs_dir.mkdir(parents=True, exist_ok=True)
672
+ # Persist last step's code into logs as step_<current_step>
673
+ write_to_path(fp=runs_dir / f"step_{current_step}{source_fp.suffix}", content=code_to_restore)
674
+
675
+ # Start Heartbeat Thread
676
+ stop_heartbeat_event.clear()
677
+ heartbeat_thread = HeartbeatSender(resume_resp["run_id"], auth_headers, stop_heartbeat_event)
678
+ heartbeat_thread.start()
679
+ current_run_id_for_heartbeat = resume_resp["run_id"]
680
+
681
+ # Seed solution panels with current and best nodes
682
+ solution_panels.update(current_node=current_solution_node, best_node=best_solution_node)
683
+
684
+ # --- Live UI ---
685
+ refresh_rate = 4
686
+ with Live(layout, refresh_per_second=refresh_rate) as live:
687
+ # Initial panels
688
+ current_solution_panel, best_solution_panel = solution_panels.get_display(current_step=current_step)
689
+ # Use backend-provided execution output only (no fallback)
690
+ term_out = resume_resp.get("execution_output") or ""
691
+ eval_output_panel.update(output=term_out)
692
+
693
+ # Update the initial panels
694
+ smooth_update(
695
+ live=live,
696
+ layout=layout,
697
+ sections_to_update=[
698
+ ("summary", summary_panel.get_display()),
699
+ ("tree", tree_panel.get_display(is_done=False)),
700
+ ("current_solution", current_solution_panel),
701
+ ("best_solution", best_solution_panel),
702
+ ("eval_output", eval_output_panel.get_display()),
703
+ ],
704
+ transition_delay=0.1,
705
+ )
543
706
 
707
+ # If missing output, evaluate once before first suggest
708
+ if term_out is None or len(term_out.strip()) == 0:
709
+ term_out = run_evaluation(eval_command=eval_command, timeout=eval_timeout)
710
+ eval_output_panel.update(output=term_out)
711
+ # Update the evaluation output panel
712
+ smooth_update(
713
+ live=live,
714
+ layout=layout,
715
+ sections_to_update=[("eval_output", eval_output_panel.get_display())],
716
+ transition_delay=0.1,
717
+ )
718
+
719
+ if save_logs:
720
+ save_execution_output(runs_dir, step=current_step, output=term_out)
721
+
722
+ # Continue optimization: steps current_step+1..total_steps
723
+ for step in range(current_step + 1, total_steps + 1):
724
+ # Stop polling
725
+ try:
726
+ current_status_response = get_optimization_run_status(
727
+ console=console, run_id=resume_resp["run_id"], include_history=False, auth_headers=auth_headers
728
+ )
729
+ if current_status_response.get("status") == "stopping":
730
+ console.print("\n[bold yellow]Stop request received. Terminating run gracefully...[/]")
731
+ user_stop_requested_flag = True
732
+ break
733
+ except requests.exceptions.RequestException as e:
734
+ console.print(f"\n[bold red]Warning: Unable to check run status: {e}. Continuing optimization...[/]")
735
+ except Exception as e:
736
+ console.print(f"\n[bold red]Warning: Error checking run status: {e}. Continuing optimization...[/]")
737
+
738
+ # Suggest next
739
+ eval_and_next_solution_response = evaluate_feedback_then_suggest_next_solution(
740
+ console=console,
741
+ step=step,
742
+ run_id=resume_resp["run_id"],
743
+ execution_output=term_out,
744
+ additional_instructions=additional_instructions,
745
+ auth_headers=auth_headers,
746
+ )
747
+
748
+ # Save next solution file(s)
749
+ write_to_path(fp=runs_dir / f"step_{step}{source_fp.suffix}", content=eval_and_next_solution_response["code"])
750
+ write_to_path(fp=source_fp, content=eval_and_next_solution_response["code"])
751
+
752
+ # Refresh status with history and update panels
753
+ status_response = get_optimization_run_status(
754
+ console=console, run_id=resume_resp["run_id"], include_history=True, auth_headers=auth_headers
755
+ )
756
+ summary_panel.set_step(step=step)
757
+ summary_panel.update_thinking(thinking=eval_and_next_solution_response.get("plan", ""))
758
+ nodes_list = status_response.get("nodes") or []
759
+ tree_panel.build_metric_tree(nodes=nodes_list)
760
+ tree_panel.set_unevaluated_node(node_id=eval_and_next_solution_response["solution_id"])
761
+ best_solution_node = get_best_node_from_status(status_response=status_response)
762
+ current_solution_node = get_node_from_status(
763
+ status_response=status_response, solution_id=eval_and_next_solution_response["solution_id"]
764
+ )
765
+ solution_panels.update(current_node=current_solution_node, best_node=best_solution_node)
766
+ current_solution_panel, best_solution_panel = solution_panels.get_display(current_step=step)
767
+ eval_output_panel.clear()
768
+ smooth_update(
769
+ live=live,
770
+ layout=layout,
771
+ sections_to_update=[
772
+ ("summary", summary_panel.get_display()),
773
+ ("tree", tree_panel.get_display(is_done=False)),
774
+ ("current_solution", current_solution_panel),
775
+ ("best_solution", best_solution_panel),
776
+ ("eval_output", eval_output_panel.get_display()),
777
+ ],
778
+ transition_delay=0.08,
779
+ )
780
+
781
+ # Evaluate this new solution
782
+ term_out = run_evaluation(eval_command=eval_command, timeout=eval_timeout)
783
+ if save_logs:
784
+ save_execution_output(runs_dir, step=step, output=term_out)
785
+ eval_output_panel.update(output=term_out)
786
+ smooth_update(
787
+ live=live,
788
+ layout=layout,
789
+ sections_to_update=[("eval_output", eval_output_panel.get_display())],
790
+ transition_delay=0.1,
791
+ )
792
+
793
+ # Final flush if not stopped
794
+ if not user_stop_requested_flag:
795
+ eval_and_next_solution_response = evaluate_feedback_then_suggest_next_solution(
796
+ console=console,
797
+ step=total_steps,
798
+ run_id=resume_resp["run_id"],
799
+ execution_output=term_out,
800
+ additional_instructions=additional_instructions,
801
+ auth_headers=auth_headers,
802
+ )
803
+ summary_panel.set_step(step=total_steps)
804
+ status_response = get_optimization_run_status(
805
+ console=console, run_id=resume_resp["run_id"], include_history=True, auth_headers=auth_headers
806
+ )
807
+ nodes_final = status_response.get("nodes") or []
808
+ tree_panel.build_metric_tree(nodes=nodes_final)
809
+ # Best solution panel and final message
810
+ best_solution_node = get_best_node_from_status(status_response=status_response)
811
+ solution_panels.update(current_node=None, best_node=best_solution_node)
812
+ _, best_solution_panel = solution_panels.get_display(current_step=total_steps)
813
+ final_message = (
814
+ f"{summary_panel.metric_name.capitalize()} {'maximized' if summary_panel.maximize else 'minimized'}! Best solution {summary_panel.metric_name.lower()} = [green]{status_response['best_result']['metric_value']}[/] 🏆"
815
+ if best_solution_node is not None and best_solution_node.metric is not None
816
+ else "[red] No valid solution found.[/]"
817
+ )
818
+ end_optimization_layout["summary"].update(summary_panel.get_display(final_message=final_message))
819
+ end_optimization_layout["tree"].update(tree_panel.get_display(is_done=True))
820
+ end_optimization_layout["best_solution"].update(best_solution_panel)
821
+
822
+ # Save best
823
+ if best_solution_node is not None:
824
+ best_solution_content = best_solution_node.code
825
+ else:
826
+ best_solution_content = read_from_path(fp=runs_dir / f"step_0{source_fp.suffix}", is_json=False)
827
+
828
+ write_to_path(fp=runs_dir / f"best{source_fp.suffix}", content=best_solution_content)
829
+ write_to_path(fp=source_fp, content=best_solution_content)
830
+ optimization_completed_normally = True
831
+ live.update(end_optimization_layout)
832
+
833
+ except Exception as e:
834
+ try:
835
+ error_message = e.response.json()["detail"]
836
+ except Exception:
837
+ error_message = str(e)
838
+ console.print(Panel(f"[bold red]Error: {error_message}", title="[bold red]Optimization Error", border_style="red"))
839
+ console.print(f"\n[cyan]To resume this run, use:[/] [bold cyan]weco resume {run_id}[/]")
840
+ optimization_completed_normally = False
841
+ finally:
842
+ signal.signal(signal.SIGINT, original_sigint_handler)
843
+ signal.signal(signal.SIGTERM, original_sigterm_handler)
844
+ stop_heartbeat_event.set()
845
+ if heartbeat_thread and heartbeat_thread.is_alive():
846
+ heartbeat_thread.join(timeout=2)
847
+
848
+ run_id = resume_resp.get("run_id")
849
+ # Report final status if run exists
850
+ if run_id:
851
+ if optimization_completed_normally:
852
+ status, reason, details = "completed", "completed_successfully", None
853
+ elif user_stop_requested_flag:
854
+ status, reason, details = "terminated", "user_requested_stop", "Run stopped by user request via dashboard."
855
+ else:
856
+ status, reason = "error", "error_cli_internal"
857
+ details = locals().get("error_details") or (
858
+ traceback.format_exc()
859
+ if "e" in locals() and isinstance(locals()["e"], Exception)
860
+ else "CLI terminated unexpectedly without a specific exception captured."
861
+ )
862
+ report_termination(
863
+ run_id=run_id,
864
+ status_update=status,
865
+ reason=reason,
866
+ details=details,
867
+ auth_headers=current_auth_headers_for_heartbeat,
868
+ )
869
+ if user_stop_requested_flag:
870
+ console.print("[yellow]Run terminated by user request.[/]")
871
+ console.print(f"\n[cyan]To resume this run, use:[/] [bold cyan]weco resume {run_id}[/]")
544
872
  return optimization_completed_normally or user_stop_requested_flag