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